Compare commits

...
359 Commits
Author SHA1 Message Date
SivanCola 15d335b86b Merge pull request #7237 from esengine/release-notes/v1.19.3
docs(release): prepare v1.19.3 notes
2026-08-03 02:05:59 +08:00
github-actions[bot] 9e9f24a77c docs(release): prepare v1.19.3 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-02 18:03:23 +00:00
SivanCola 427fba0f27 Merge pull request #7198 from esengine/fix/background-job-visibility
fix(desktop): make blocked Delivery runtimes recoverable
2026-08-03 01:52:13 +08:00
SivanCola ae309c967a fix(desktop): preserve job ownership boundaries
Problem:
Network probes bypassed permission prompts, and local background jobs could not be listed or stopped while Remote Workbench was active.

Root cause:
The shared read-only classifier conflated workspace mutation with permission safety, while job APIs selected the active remote target before honoring an explicit tab owner.

Fix:
Separate workspace-nonmutating commands from permission-read-only commands, keep Test-NetConnection outside read-only subagents, and route job listing and cancellation by explicit local or remote tab ownership.

Verification:
Ran focused permission, evidence, agent, and Desktop tests, both Go module suites, race tests, vet, and the cache guard.
2026-08-03 01:27:16 +08:00
SivanCola df04b11b06 Merge pull request #7227 from SivanCola/feature/stable-only-release-flow
feat(release): keep one official Stable publication path / 保留单一正式版发布路径
2026-08-03 01:14:37 +08:00
SivanCola ab24371d98 fix(release): bind stable tags to reviewed candidate
Problem:
The stable helper could consume immutable version tags if main-v2 advanced while exact-SHA CI or the protected relay was starting. Manual CLI and Desktop recovery also still accepted RC tags despite the official-only release contract.

Root cause:
The helper sampled main-v2 once, pushed only tag refs, and the orchestrator required the tags to equal the later branch tip. The candidate was not proven to be the commit that changed the target reviewed Notes record.

Fix:
Bind normal candidates to their reviewed Notes change, revalidate exact push CI from the protected control plane, include main-v2 as a guarded ref in the atomic tag transaction, and accept the immutable candidate after later protected branch advances. Restrict manual CLI and Desktop recovery to official versions while retaining historical reusable-workflow parsing.

Verification:
- bash scripts/release-stable.test.sh
- bash scripts/release-workflows.test.sh
- node --test scripts/release-notes.test.mjs
- actionlint on all active release workflows
- site contract tests, SignPath contract tests, Desktop control-plane test, cache guard, and git diff --check
2026-08-03 01:05:09 +08:00
SivanCola eee6334921 Merge pull request #7199 from SivanCola/feature/v1.20-remove-guard-update-v2
Remove Guard and make Desktop updates atomic / 移除 Guard 并实现桌面端原子更新
2026-08-03 00:55:38 +08:00
SivanCola 6bd942a090 test(release): retire the Desktop prerelease relay contract
The Desktop CI still opened release-desktop-trigger.yml after the public prerelease relay was intentionally removed, causing the first PR head to fail.

Keep the protected Stable relay assertions and replace the deleted relay expectation with explicit absence checks for all three retired public prerelease workflows.

Verification:
go -C desktop test . -run TestProductionSigningRunsOnlyFromProtectedControlPlane -count=1
bash scripts/release-workflows.test.sh
git diff --check
2026-08-02 23:50:39 +08:00
SivanCola 97b42efe4a Merge latest main-v2 site navigation
Problem: main-v2 advanced after the lint repair was prepared, so the PR branch would otherwise become stale again before push.

Root cause: PR #7226 merged the shared site GitHub navigation contract during final verification.

Fix: Merge the latest base without changing the Desktop update implementation.

Verification: pnpm test in site passes all 48 contract tests; git diff --cached --check passes.
2026-08-02 23:49:25 +08:00
SivanCola 94f627c535 Remove obsolete repair state locks
Problem: The PR lint job failed because lockRepairStateFile was unused after the StartupTracker write path was removed.

Root cause: The Unix, Windows, and fallback platform helpers were left behind when startup recovery state became read-only diagnostics.

Fix: Delete the three dead platform implementations so no Guard-era repair-state lock surface remains.

Verification: golangci-lint v2.12.2 reports 0 issues; go test and go vet pass for internal/repair; the package cross-compiles for Windows amd64.
2026-08-02 23:48:53 +08:00
SivanCola 087aaa3a9b chore(release): retire the npm alias migration action
The one-time migration completed successfully in Actions run 30754751693. All seven packages now resolve latest, canary, and next to 1.19.2 with candidate provenance c46e3af1c2.

Remove the temporary manual workflow and its actionlint entry so the normal developer surface contains only ongoing release operations.

Verification:
Re-ran release workflow contracts, site tests, SignPath contract validation, actionlint, cache guard, and diff checks after removal.
2026-08-02 23:47:34 +08:00
SivanCola 93b47a036a Merge pull request #7226 from SivanCola/feature/site-github-header
Add GitHub header link and responsive navigation / 添加 GitHub 入口并优化响应式导航
2026-08-02 23:47:28 +08:00
SivanCola 08e8e79bcf Merge latest main-v2 release fixes
Problem: main-v2 advanced again while the updater conflict resolution was under verification, leaving the branch behind the current base.

Root cause: PR #7222 merged an npm alias propagation fix after the first integration merge was prepared.

Fix: Merge the new base commit without altering the resolved Desktop updater semantics.

Verification: node --test scripts/finalize-npm-official-release.test.mjs; bash scripts/release-workflows.test.sh; git diff --cached --check.
2026-08-02 23:43:29 +08:00
SivanCola 8847d5b9b8 feat(release): keep one official Stable publication path
Problem:
The dual-channel release controls and the proposed GitHub App activation add operator choices, owner-only setup, and failure modes that are unnecessary now that users consume only official releases.

Design:
Retain the proven three-tag Stable orchestrator, retire public Preview relays, and add one exact-SHA helper that validates reviewed notes and push CI before atomically creating the CLI, npm, and Desktop tags. Keep historical Preview inputs readable only for compatibility.

Experience:
Prepare release accepts one version. After the notes PR merges, a maintainer runs one command and approves the release environment once. The website and public documentation expose only the official line and reasonix upgrade.

Safety:
Preserve immutable tags, protected control-plane checks, SignPath validation, idempotent recovery, npm provenance checks, and bounded alias convergence. No persisted runtime format changes are introduced.

Verification:
Release workflow contracts, atomic tag failure cases, SignPath contract tests, website public-surface tests, actionlint, cache guard, and diff checks pass locally.
2026-08-02 23:43:21 +08:00
SivanCola 99cf86bdf1 Merge main-v2 and preserve atomic official updates
Problem: PR #7199 conflicted with the official single-channel updater changes on main-v2, which prevented GitHub from producing a merge ref and running the full CI matrix.

Root cause: Both branches changed the Desktop updater settings, Wails contracts, configuration compatibility fields, and updater tests from the same older baseline.

Fix: Merge main-v2 while retaining the single ApplyUpdateRequest flow, force every new UI operation onto the official stable-compatible channel, remove the retired channel switch helper and Safe Mode payload, and preserve request/version fences for stale async completions.

Verification: GOWORK=off go test ./... and go vet ./... in both Go modules; focused go test -race for config, install layout, window state, settings, and updater paths; pnpm test:all and pnpm build; Windows amd64 Desktop and update-helper cross-builds; cache guard, cache-impact, and release workflow contract tests.
2026-08-02 23:43:07 +08:00
SivanCola da051f9448 feat(site): add GitHub header navigation
Problem: The shared marketing header did not expose the source repository, duplicated the install destination with a Get started link, and allowed navigation text to overflow into right-side controls at intermediate widths.

Root cause: The responsive rules kept a non-shrinking navigation row visible until 900px even when its flex container was already narrower than its contents.

Fix: Add an accessible GitHub link, collapse the primary navigation before it can overlap, preserve the compact mobile control budget, remove the duplicate Get started item, and cover the contracts with focused tests.

Verification: npm test in site (48 tests passed); npx astro build; browser checks at 1101px, 1100px, 952px, and 900px in English and Chinese.
2026-08-02 23:41:43 +08:00
SivanCola 42a21500f9 Merge pull request #7222 from esengine/fix/npm-alias-propagation
fix(release): tolerate npm alias propagation delay
2026-08-02 23:35:45 +08:00
SivanCola 75c4308810 fix(release): wait for npm alias propagation 2026-08-02 23:13:57 +08:00
SivanCola 37360241df Merge pull request #7219 from SivanCola/chore/migrate-npm-release-aliases
chore(release): align legacy npm aliases
2026-08-02 23:09:08 +08:00
SivanCola dfc9f19563 chore(release): align legacy npm aliases
Problem:
The bridge release advanced npm latest to 1.19.2 while the compatibility canary and next aliases still selected public prereleases.

Root cause:
The legacy Stable publisher owns latest only, and the single-channel finalizer is not active yet.

Fix:
Add a zero-input, release-approved one-time workflow that verifies all seven package records against the exact 1.19.2 candidate SHA before atomically aligning latest, canary, and next. Fail closed on provenance drift and verify every final alias.

Verification:
node --test scripts/finalize-npm-official-release.test.mjs
bash scripts/release-workflows.test.sh
go run github.com/rhysd/actionlint/cmd/[email protected] .github/workflows/migrate-npm-release-aliases.yml .github/workflows/ci.yml
git diff --check
2026-08-02 23:00:41 +08:00
SivanCola cea216a57a Make Desktop updates failure-atomic
Problem:
- current.json could degrade to an in-place copy, legacy helpers could reject valid pending updates without relaunching, overlapping window saves could reorder, and manual NSIS installs wrote live layout state directly.

Root cause:
- Commit-pointer writes shared a compatibility fallback, the detached helper resolved pending state from its cache location, frontend saves were concurrent, and the installer bypassed the layout activation transaction.

Fix:
- Add strict atomic pointer writes, claim legacy transactions against the explicit install root with recovery relaunch, serialize window capture/persistence, and route normal installers through signed staging activation.
- Add failure-injection, ordering, handoff, and packaging contract tests.

Verification:
- GOWORK=off go test ./... && GOWORK=off go vet ./... (root and desktop modules)
- GOWORK=off go test -race on affected root and desktop packages
- pnpm test:all && pnpm build
- Windows amd64 cross-compile for migrator, update helper, and desktop
- ./scripts/cache-guard.sh and cache impact check
2026-08-02 21:57:19 +08:00
SivanCola 876c3dff56 Remove remaining Safe Mode code paths
Problem:
- Product Safe Mode had been disabled semantically, but boot, theme, telemetry, settings, and startup tracking still carried always-false branches and compatibility fields.

Root cause:
- The initial Guard removal preserved obsolete behavior APIs instead of reducing them to the normal startup and appearance paths.

Fix:
- Flatten boot onto the normal integration path, remove Safe Mode fields and telemetry gates, and keep legacy startup state read-only for crash diagnostics.
- Remove obsolete frontend/theme contracts and update recovery documentation.

Verification:
- GOWORK=off go test ./... && GOWORK=off go vet ./... (root and desktop modules)
- GOWORK=off go test -race ./internal/boot ./internal/telemetry ./internal/repair ./internal/fileutil ./internal/installlayout
- pnpm test:all && pnpm build
- ./scripts/cache-guard.sh and cache impact check
2026-08-02 21:57:02 +08:00
SivanCola c46e3af1c2 Merge pull request #7214 from esengine/release-notes/v1.19.2
docs(release): 准备 v1.19.2 中英更新日志
2026-08-02 21:53:25 +08:00
SivanCola c2f4976815 docs(release): refine v1.19.2 release notes
Summary:
List all affected user-facing subsystems and correct two English descriptions in the generated stable release notes.

Verification:
Validate the release catalog, run its tests, render the exact public notes, scan them for private data, and check patch formatting.
2026-08-02 21:52:30 +08:00
github-actions[bot] 2a6064c75b docs(release): prepare v1.19.2 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-02 13:51:21 +00:00
SivanCola f72874d49b Merge pull request #7209 from SivanCola/fix/windows-instruction-home-test
test(instruction): isolate Windows home imports / 隔离 Windows Home 测试
2026-08-02 20:39:03 +08:00
SivanCola 6849308cef ci(windows): cover instruction home resolution
Summary:
Add the instruction package to the bounded Windows pull-request smoke suite so platform-specific home expansion is checked before merging.

Verification:
Run the instruction package, lint the CI workflow with actionlint v1.7.7, and check patch formatting.
2026-08-02 20:19:25 +08:00
SivanCola 6076a35731 test(instruction): isolate Windows home imports
Summary:
Set both Unix and Windows home environment variables in instruction resolver tests so tilde expansion remains inside each temporary test home.

Verification:
Run the instruction package once, repeat the three home-import cases 20 times, and check patch formatting.
2026-08-02 20:18:28 +08:00
SivanCola a7acd8a3f4 fix(desktop): keep runtime recovery actions reachable
Problem:
Detached runtimes without child jobs disappeared from the status bar, Remote Workbench jobs could be replaced by local groups, and displayed runtime decision shortcuts did not work.

Root cause:
The status chip treated jobs.length as the only activity signal and selected one data source. RuntimeDecisionCard only listened for Escape.

Fix:
Keep running and prompt-blocked runtimes visible, merge active remote jobs with local runtime groups, expose actionable runtime state copy, and route enabled number keys to their matching actions.

Verification:
Ran the full frontend suite and production build, both Go module suites, focused and Desktop race suites, cache guards, targeted DOM interaction tests, and a local browser smoke check.
2026-08-02 20:10:20 +08:00
SivanCola 2afbe59efc Merge pull request #7207 from esengine/release-notes/v1.19.2-preview.1
docs(release): 准备 v1.19.2-preview.1 中英更新日志
2026-08-02 20:09:59 +08:00
SivanCola 564c5a710b docs(release): correct v1.19.2 preview surfaces
Summary:
List every user-facing subsystem covered by the bridge release notes instead of classifying the release as Desktop-only.

Verification:
Validate the release catalog, render the exact public notes, scan the rendered text for private data, and check the patch formatting.
2026-08-02 20:07:41 +08:00
github-actions[bot] dc2fdebe97 docs(release): prepare v1.19.2-preview.1 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-02 12:06:15 +00:00
SivanCola ede7ceef00 Merge pull request #7201 from SivanCola/codex/single-channel-runtime
feat(update): migrate clients to the official release channel
2026-08-02 20:00:46 +08:00
SivanCola d8a4288862 fix(desktop): remove obsolete safe mode paths
Problem: v1.20 no longer enters product Safe Mode, but the desktop still carried unreachable startup recovery, theme gating, renderer fallback, and platform helper paths that kept lint red.

Root cause: the initial Guard removal disconnected the entry points without deleting their dormant implementations or moving Linux-only helpers behind the Linux build tag.

Fix: delete the obsolete recovery and renderer policy, simplify theme operations to the normal path, retain only ignored legacy launch tokens and always-false wire fields for upgrade compatibility, and clear the remaining Desktop lint findings.

Verification: go test ./...; go vet ./...; golangci-lint run ./...; pnpm typecheck; pnpm exec tsx src/__tests__/theme-pack.test.ts; Windows amd64 cross-compile.
2026-08-02 19:45:37 +08:00
SivanCola 9546b1f314 ci: refresh cache impact metadata 2026-08-02 19:39:20 +08:00
SivanCola cc38074026 feat(update): migrate clients to official release channel 2026-08-02 19:34:27 +08:00
SivanCola 5664dc8ba7 fix(desktop): clear Guard removal lint blockers
Problem:
Guard removal left two unused Safe Mode internals and a no-op legacy argument check, causing golangci-lint to fail.

Root cause:
The compatibility APIs were made constant-false but their obsolete storage/helper remained, while the migrator prefix branch only continued at the natural end of the loop.

Fix:
- Remove the unused Config.safeMode field and loadSafeModeForRoot alias.
- Remove the side-effect-free legacy prefix check; unknown compatibility arguments remain ignored.

Verification:
- go test ./internal/config ./cmd/reasonix-legacy-migrator
- go vet ./internal/config ./cmd/reasonix-legacy-migrator
- golangci-lint run ./internal/config ./cmd/reasonix-legacy-migrator
- CACHE_IMPACT_PR_BODY_FILE=/tmp/reasonix-v120-guard-removal-pr.md ./scripts/check-cache-impact.sh
- ./scripts/cache-guard.sh
- git diff --check
2026-08-02 19:22:16 +08:00
SivanCola d8c319d144 fix(desktop): harden Guard-free update migration
Problem:
The initial Guard removal still left old 1.18-1.19.1 clients without a reliable migration handoff, allowed corrupt current.json files to fall back to stale flat binaries, and kept legacy pending transactions in the versioned update path.

Root cause:
Launcher and migrator ownership was split, version activation did not publish stable root entries transactionally or serialize competing processes, and Windows/Linux versioned updates still inherited parts of the flat-layout recovery protocol.

Fix:
- Share permanent launcher behavior and run the one-shot compatibility migrator before resolving the active desktop.
- Finalize exact legacy pending transactions during flat-to-versioned migration and archive only strictly older install-local transactions after a healthy recovery install.
- Publish version directories and root launcher/CLI entries under a cross-process activation lock, with current.json as the final commit point and rollback on failure.
- Remove legacy pending identities from Windows versioned handoff and activate Linux versioned updates without persisting Guard.
- Make config file merging transactional, hold one update request owner across all phases, and align Linux deb/frontend contracts with the Guard-free product behavior.
- Add migration, rollback, packaging, concurrency, compatibility, and legacy environment regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- targeted go test -race for config, installlayout, repair, CLI, launcher, migrator, and updater paths
- go vet ./... in both Go modules
- pnpm test:updater and pnpm build in desktop/frontend
- cache guard and cache-impact checks
- Windows amd64 desktop/update-helper and Linux amd64 launcher/migrator/update-helper cross-compilation
2026-08-02 18:46:03 +08:00
SivanCola cf17cad099 fix(desktop): recover blocked delivery runtimes 2026-08-02 18:45:21 +08:00
SivanCola 51a7b83e28 fix(desktop): restore background job controls 2026-08-02 18:32:18 +08:00
SivanCola 9fede8b281 feat(desktop): finish v1.20 remaining fault tolerance, API and packaging
Problem:
v1.20 still lacked local config recovery, kept StartupTracker/old split update
bindings in the product surface, and packaged flat Windows/Linux layouts with
Guard assumptions.

Root cause:
LoadForRoot failed closed on corrupt TOML; desktop still bound download/install
as separate Wails methods; portable and Linux packaging still assumed a flat
Guard release unit; helper still allowed a flat publish fallback.

Fix:
- Config load: corrupt user config uses last-known-good in memory (else
  built-in defaults) without rewriting the original file; project config and
  MCP JSON failures are isolated with LoadWarnings.
- Desktop surfaces configWarnings with Open config / Reload / doctor repair
  banner actions; records LKG after UI ready and on clean shutdown.
- Remove StartupTracker from launch control (observe previous run for crash
  diagnostics only).
- Wails update surface is ApplyUpdateRequest only; download/install are private.
- Helper versioned-v1 activation is mandatory for staged Windows payloads.
- Windows portable packaging/verification and Linux tarball use versioned/thin
  launcher contracts; macOS bundle rejects Guard.
- Fault-injection and legacy flat migration fixture tests for installlayout.

Verification:
- go test ./internal/config ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/ and focused package tests
- pnpm --dir desktop/frontend test:updater (31 passed)

Out of scope for local automation: Windows DPI 100x soak, Preview 72h gate,
production migration success rate, and live signed-installer UI coverage.
2026-08-02 18:05:26 +08:00
SivanCola 79449ef801 feat(desktop): versioned helper activation, drop Guard, NSIS dual layout
Problem:
The Windows update helper still published a flat six-member release unit with
Guard health recovery. The Guard recovery product remained as a first-class
binary and packaging still installed Guard as the permanent entry point.

Root cause:
Post-stage publish was hard-wired to flat InstallRoot members and pending
receipt recording. Packaging and docs still treated reasonix-guard as the
launcher/recovery surface.

Fix:
- Prefer versioned-v1 activation from staged NSIS payloads: write
  versions/<ver>/{desktop,cli,helper}, swap current.json, place thin launcher
  and portable alias at InstallRoot, remove flat desktop/guard leftovers.
- On versioned success, clear the pending transaction without health probation
  and relaunch via reasonix-launcher.
- Resolve InstallRoot and update-helper source for versioned trees; claim
  pending targets from the prepared transaction rather than hard-coded flat
  paths.
- NSIS dual mode: /REASONIXSTAGE=1 still extracts the six-member flat payload
  for 1.18-1.19.1 helpers; normal install writes versioned layout and does not
  persist Guard.
- Delete cmd/reasonix-guard. Compatibility payloads still build
  reasonix-guard from reasonix-legacy-migrator only. Point recovery docs at
  reasonix doctor.
- Linux .desktop Exec uses reasonix-launcher.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/
- focused desktop packaging/window/updater tests
- GOOS=windows go test -c ./cmd/update-helper and desktop package
2026-08-02 17:54:11 +08:00
SivanCola 0e78ef425e feat(desktop): start v1.20 Guard removal and versioned update layout
Problem:
Desktop shutdown can panic on Windows when Wails queries window geometry after DPI reports 0. Product Safe Mode and Guard health policy force global capability loss and leave users stuck on pending-update loops. The flat install layout cannot support atomic version activation.

Root cause:
beforeClose/shutdown called WindowGetSize/Position/IsMaximised on a tearing-down WebView2 surface. StartupTracker and REASONIX_SAFE_MODE drove a process-wide degraded mode. Updates relied on a single pending transaction with health probation and Guard rollback.

Fix:
- Persist window state only from frontend reports; Go rewrites last-known state without native queries during hide/shutdown. Accept Windows border origin x=-8,y=-8 and add regression coverage.
- Introduce installlayout (current.json schema 1, staging activation, version GC) plus a permanent thin reasonix-launcher and a one-shot legacy migrator (compat binary name reasonix-guard).
- Add ApplyUpdateRequest single-action update path, install_layout field validation, and frontend update-and-restart UX.
- Make SafeModeRequested always false; strip desktop startup recovery that forced Safe Mode; keep doctor/crash diagnostics non-gating.
- Point desktop-build Windows launcher/migrator builds at the new cmds.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher ./cmd/reasonix-legacy-migrator ./internal/config ./internal/boot
- focused desktop window-state/updater/safe-mode tests
- pnpm --dir desktop/frontend test:updater (31 passed)

Remaining for full v1.20.0 gate: NSIS dual-mode packaging, versioned payload activation in update-helper, delete cmd/reasonix-guard source, full config last-known-good resilience, macOS/Linux packaging and release docs.
2026-08-02 17:43:03 +08:00
SivanCola 0d42ffbbdc Merge pull request #7109 from sudoviber/fix/7059-suppress-tool-call-reasoning-warning
fix(agent): rate-limit missing tool-call reasoning warnings / 按配置限频工具调用思考告警
2026-08-02 16:57:35 +08:00
SivanCola 1c62489dac Merge pull request #7159 from SivanCola/fix/theme-safe-code-highlighting
Keep syntax highlighting readable across themes / 保持不同主题下的代码高亮清晰
2026-08-02 09:22:09 +08:00
SivanCola cb74987bd9 Merge pull request #7146 from Q1hangL/fix/delivery-readiness-verifier-summary
fix(agent): guide delivery readiness verification recovery / 引导 Delivery 就绪验证恢复
2026-08-02 09:20:57 +08:00
SivanCola 3add2995d8 fix(agent): preserve warning resolution ordering
Problem:
Delayed missing-reasoning transactions could revive an incident after a newer healthy turn. Future last-missing timestamps and transient healthy-state write failures could then suppress a later regression despite the warning contract.

Root cause:
The persisted record kept only missing timestamps and deleted healthy incidents, so it had no resolution watermark to reject stale claims. State loading validated only the warning timestamp, and resolve writes discarded persistence errors.

Fix:
Keep a bounded nanosecond resolution watermark in the additive v2 state, reject inconsistent or future event timestamps, and keep non-missing read failures visible. Preserve failed healthy resolutions in the agent and retry them before the next missing claim, falling back to a visible warning if persistence is still unavailable. Add ordering, recovery, migration, and partial-read regression coverage.

Verification:
- go test -count=1 ./...
- go test -race -count=1 ./internal/agent
- go test -count=1 ./... (desktop module)
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh origin/main-v2
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 09:05:12 +08:00
SivanCola 1fd5d7bb99 fix(evidence): reject non-checking tsc modes
Problem:
TypeScript commands such as tsc --noEmit --init could write tsconfig.json while being treated as non-mutating verification. Metadata, watch, build, and --noCheck modes could also satisfy or stall readiness without completing a bounded type check.

Root cause:
The TypeScript verifier only required --noEmit and rejected a small set of independent output destinations. It did not distinguish ordinary compiler checks from control and early-exit modes.

Fix:
Fail closed on init, help/version/config display, list-only, no-check, watch, build/clean, trace/profile, and explicit build-info destinations for both direct tsc and npx tsc. Preserve ordinary one-shot no-emit checks and add positive and negative classifier regressions.

Verification:
- go test ./internal/evidence -run "VerificationCommandSummary|GoBuild|TSCVerification|PythonCompileall|NpxVerification|RunnerWriteOutput" -count=1
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check
2026-08-02 09:03:00 +08:00
SivanCola 85de763b42 fix(desktop): guarantee contrast across diff surfaces
Problem:
Valid midtone custom themes could make semantic or syntax colors fail WCAG AA on either the plain code surface or a tinted diff row.

Root cause:
A fixed diff tint could cross the black/white contrast boundary, leaving no shared foreground that reached 4.5:1 across every rendered surface.

Fix:
Reduce semantic row tint only when required, validate every code role against plain, added, and deleted backgrounds, and add adversarial plus generated-palette regressions.

Verification:
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- go test ./...
- go test -race ./internal/cli -count=1
- cd desktop && go test ./...
2026-08-02 09:00:58 +08:00
SivanCola 2e9680edd0 Merge pull request #7164 from SivanCola/fix/mcp-startup-recovery
fix(mcp): keep slow startup alive in background / 修复 MCP 慢启动反复重启
2026-08-02 08:42:37 +08:00
SivanCola 4a989f4d6a fix(mcp): flush startup stderr before reporting
Problem:
The Linux race job intermittently observed an initialize timeout before the stdio stderr copy goroutine had flushed, so structured startup diagnostics omitted the expected redacted stderr tail.

Root cause:
Startup failure paths sampled the bounded stderr buffer before closing and reaping the transport, even though transport close is the synchronization point for the copy goroutine.

Fix:
Close the failed transport first, then read and redact startup stderr for initialize and both tools/list paths.

Verification:
- go test -race ./internal/plugin -run ^TestStartupFailureReportsStageElapsedAndRedactedStderr$ -count=20
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- go test -p 4 ./... -count=1
- go vet ./internal/plugin
- ./scripts/cache-guard.sh
- git diff --check
2026-08-02 08:35:39 +08:00
SivanCola 1f3a67b57e fix(agent): serialize warning state transactions
Problem:
After same-incident coalescing fixed duplicate Windows warnings, concurrent claims for different configurations could still lose persisted incidents.

Root cause:
Each fingerprint had its own leader, so distinct leaders still competed in the bounded in-process portion of the file lock. Slow Windows atomic writes let queued leaders time out before their read-modify-write transaction.

Fix:
Serialize all transactions for one warning-state file with a process-local mutex before entering the bounded cross-process file lock. Same-fingerprint flights still coalesce duplicate notices, while distinct configurations now complete lossless read-modify-write transactions.

Verification:
- go test ./internal/agent -run TestMissingReasoningWarnStateConcurrent -count=100
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/agent

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:23:28 +08:00
SivanCola be1d9c359c fix(evidence): reject npx runner aliases
Require npx verifier identities to use a bare known package name with only an ordinary version or dist-tag suffix. Reject filesystem paths and package protocols so aliases such as eslint@npm:other-package cannot inherit the trusted runner classification.
2026-08-02 03:20:19 +08:00
SivanCola e94e549799 fix(mcp): fence background startup removal
Problem:
A server removal could race the narrow gap between a background handshake finishing tools/list and publishing its client, allowing the removed server to reappear.

Root cause:
EnsureConnectedInBackground registered a deferred generation but did not pass that generation into the final Host publication check.

Fix:
Carry the generation through the shared connection path, retain configured server names in connection status, and cover removal during an in-flight background startup.

Verification:
- go test ./internal/plugin -count=1
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- go test -p 4 ./... -count=1
- git diff --check
2026-08-02 03:15:34 +08:00
SivanColaandipanta 1522bac58d fix(evidence): harden verifier output boundaries
Problem:
Delivery recovery still accepted bytecode compilation and TypeScript diagnostic output as non-mutating verification. The npx policy also recognized only two test runners, leaving safe test and lint commands from #6901 behind the Auto Guard boundary.

Root cause:
The verifier allowlist treated compileall as a check, only gated tsc on noEmit, and did not distinguish safe npx test/check modes from output-producing runners and flags.

Fix:
Classify compileall and explicit TypeScript build-info, trace, and CPU-profile destinations as mutations. Adapt the known-runner direction from #6901 for Mocha, AVA, ESLint, Prettier check mode, and no-emit npx tsc while keeping Playwright, Cypress, source executors, control flags, and output modes fail closed.

Verification:
- go test ./internal/evidence -run 'VerificationCommandSummary|GoBuild|TSCVerification|PythonCompileall|NpxVerification|RunnerWriteOutput' -count=1
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check

Co-authored-by: ipanta <[email protected]>
2026-08-02 03:14:11 +08:00
SivanCola 558fb5d474 Merge latest main-v2 into PR #7109
Refresh the Windows concurrency repair after main-v2 advanced with v1.19.1 release notes. The base-only documentation update does not overlap executable code.
2026-08-02 03:11:30 +08:00
SivanCola 969f7cd436 fix(agent): coalesce concurrent reasoning incidents
Problem:
Windows CI could emit several first warnings for one concurrent incident even though persistent state used a file lock.

Root cause:
The bounded file-lock deadline also covered the in-process queue. On slower Windows atomic writes, followers timed out locally and each took the fail-visible path.

Fix:
Coalesce in-process claims by state path and configuration fingerprint. Followers suppress duplicate warnings while forwarding their latest observation timestamp to the leader, which preserves stale-health ordering and retains the bounded cross-process lock behavior.

Verification:
- go test ./internal/agent -run TestMissingReasoningWarnStateConcurrent -count=50
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh
- CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/agent

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:11:06 +08:00
SivanCola 5c16cc1408 fix(mcp): keep slow startup alive in background
Problem:
Cold MCP servers that exceeded the short interactive wait were canceled and restarted on every retry, obscuring the real startup stage and source.

Root cause:
The caller wait context also owned the shared initialize and tools/list lifecycle, so a five-second timeout terminated otherwise healthy cold starts.

Fix:
Separate the session-owned startup lifecycle from the caller wait, add bounded global and per-server startup timeouts, preserve pinned provider-visible tool schemas, and expose redacted structured diagnostics.

Verification:
- go test -p 4 ./... -count=1
- go test -race ./internal/plugin ./internal/control ./internal/agent -count=1
- ./scripts/cache-guard.sh
- go vet ./internal/plugin ./internal/config ./internal/boot ./internal/capdiag ./internal/control ./internal/agent ./internal/installsource
- git diff --check
2026-08-02 03:07:24 +08:00
SivanCola 9053b3be17 Merge pull request #7163 from esengine/release-notes/v1.19.1
docs(release): prepare v1.19.1 notes / 准备 v1.19.1 更新日志
2026-08-02 03:06:59 +08:00
github-actions[bot] dc152cc60c docs(release): prepare v1.19.1 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-01 19:05:17 +00:00
SivanCola 3ad7439f92 Merge latest main-v2 into PR #7109
Refresh the repaired warning-state branch after the base advanced during verification. The intervening billing, remote protocol, and release-note changes do not overlap the incident-state implementation.
2026-08-02 03:00:33 +08:00
SivanCola 86a4958057 fix(agent): re-arm missing reasoning diagnostics
Problem:
Persisting a provider-name-only acknowledgment hid endpoint/model changes and future regressions, while OpenAI-format V4 Flash was inconsistent with the current DeepSeek thinking contract.

Root cause:
Warning state had no configuration identity, expiry, or healthy recovery transition, and the OpenAI warning matcher excluded the whole Flash family.

Fix:
Key incidents by an opaque adapter configuration fingerprint, expire them after 24 hours, clear them after healthy tool-call reasoning, and preserve ordering with millisecond event timestamps under the existing cross-process lock. Align known DeepSeek thinking models across OpenAI, Anthropic, and Responses adapters and use protocol-neutral diagnostics.

Verification:
- go test -race ./internal/agent
- go test ./...
- go vet ./...
- golangci-lint run --timeout=5m
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-02 03:00:14 +08:00
SivanCola 3c5e51a214 Merge origin/main-v2 into fix/theme-safe-code-highlighting
Synchronize the latest release, desktop packaging, billing, and remote protocol changes before publishing the theme readability integration. The upstream paths do not overlap the theme or syntax-highlighting changes.
2026-08-02 02:54:51 +08:00
SivanColaandHaoyueQin 10c79d6e8b fix(desktop): preserve code contrast across pane themes
Problem:
Theme packs, Creation overrides, and diff tint layers could leave syntax or semantic diff text below WCAG AA, while the extended pane transparency rules could make protected code surfaces translucent again.

Root cause:
Base styles did not install complete code palettes, Creation replaced only the code background, diff contrast was measured against the untinted surface, and the pane-tier integration treated block code as an ordinary child panel.

Fix:
Generate complete opaque palettes for every base style and Creation, derive foregrounds against final diff row backgrounds, share the derivation with previews, exempt block code and diffs from pane transparency, and clear every extended pane variable.

Verification:
- pnpm test
- pnpm build
- go test ./...
- go test -race ./...
- in-app browser light, dark, and background-theme contrast checks

Co-authored-by: HaoyueQin <[email protected]>
2026-08-02 02:54:28 +08:00
SivanCola 938f21321a Merge pull request #7161 from esengine/release-notes/v1.19.0-preview.3
docs(release): prepare v1.19.0-preview.3 notes / 准备 v1.19.0-preview.3 更新日志
2026-08-02 02:54:25 +08:00
github-actions[bot] 139bee9eba docs(release): prepare v1.19.0-preview.3 notes
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.
2026-08-01 18:52:41 +00:00
SivanCola 2e7a01914a Merge pull request #7160 from SivanCola/fix/balance-currency-alignment
Align wallet balance with pricing currency / 统一余额与费用币种
2026-08-02 02:49:59 +08:00
SivanCola 49fad9d6f8 Merge main-v2 into PR #7109
Bring the warning-state fix onto the current provider and agent baseline before refining its persistence semantics. This keeps the contributor branch mergeable without rewriting its public history.
2026-08-02 02:47:28 +08:00
SivanCola bf8490df5b fix(evidence): fail closed on output-capable verifiers
Problem:
Go build package patterns can still write a workspace binary when they expand to one main package. The recovery summary also rendered only family labels, hiding safe examples such as tsc --noEmit while the classifier accepted output-capable bare tsc commands.

Root cause:
The static classifier assumed ./... always implied discarded build outputs, and the summary discarded the same concrete examples used by its consistency tests. TypeScript verification was not gated on no-emit mode.

Fix:
Classify every go build form as a mutation, render each tested safe command example in the recovery summary, and require explicit TypeScript no-emit mode. Update the serial sign-off scenario and add regressions for Go build, TypeScript output flags, and model-visible examples.

Verification:
- go test ./internal/evidence ./internal/agent -count=1
- go test -race ./internal/evidence ./internal/agent -count=1
- go vet ./...
- go test ./...
- bash scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:46:51 +08:00
HaoyueQin 9df2ec56d9 feat(desktop): complete theme pane transparency, hover consistency, and dock-tab compression
- Five-tier pane transparency system (Shell / Card / Session-hover /
  Child / Interact) with CSS variables driven by paneOpacity slider
- Sidebar session items (project-tree__topic) and folders now share
  the same session-hover tier, responding to panel opacity uniformly
- Welcome page example buttons (welcome__ex) placed on child tier,
  one level above the welcome container background
- Topicbar buttons + external-opener unified on card tier
- Update banner (banner--update) raised to child tier
- Active dock-tab underline moved inside button (bottom:-1px→1px)
  so it is no longer clipped by content below
- Windows frameless dock-tab compression: scoped to workbench layout
  only, compresses spacing (padding / gap) via clamp() while never
  shrinking tabs or truncating labels

(cherry picked from commit fad7908b0f)
2026-08-02 02:43:24 +08:00
SivanCola 14bf219237 fix(desktop): align wallet balance with pricing currency
Problem:
The status bar could show a USD session cost beside a CNY wallet balance after the user selected USD pricing.

Root cause:
Balance rendering always preferred CNY and neither the local nor Remote Workbench balance query carried the effective Desktop pricing currency.

Fix:
Select the provider-reported balance that matches the effective Settings currency. When the provider only reports another currency, retain that real amount with an explicit ISO prefix instead of applying an implicit exchange rate. Carry the preference through an optional Remote Workbench request field while preserving the legacy CNY-first fallback for older callers.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- pnpm typecheck && pnpm test:typecheck && pnpm test:remote
- pnpm exec tsx src/__tests__/use-controller-live-context.test.tsx
- pnpm build
- go run ./cmd/remote-protocol-gen -check -root .
- ./scripts/cache-guard.sh
2026-08-02 02:33:21 +08:00
SivanCola c18a3adcd0 Merge pull request #7123 from LouF666/fix/macos-icon-safe-area
fix(desktop): restore macOS icon safe area
2026-08-02 02:31:05 +08:00
SivanCola b0d7805b02 fix(desktop): protect syntax contrast across themes
Problem:
Code and diff surfaces inherited translucent theme backgrounds while syntax colors followed only the global light/dark mode. Custom backgrounds could therefore bleed through and make highlighted text unreadable.

Root cause:
Theme-pack overlays forwarded bgSoft alpha directly and had no derived code surface or per-token contrast guard. The theme preview also omitted realistic code and diff content.

Fix:
Derive an opaque code background and border from each theme mode, choose the syntax direction from the final surface luminance, and minimally adjust every code and diff text role to WCAG AA contrast. Route code, full diff, and inline diff rendering through the protected surface, and add live code/diff samples to theme previews.

Verification:
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- cd desktop && go test ./...
- go test ./...
- go test -race ./internal/cli -count=1
- in-app browser: light/dark, home/task, official background, and #RRGGBBAA custom bgSoft
2026-08-02 02:29:43 +08:00
SivanCola 484263db3f fix(cli): follow theme mode for diff syntax colors
Problem:
CLI diffs kept the GitHub dark syntax palette on light terminal themes, leaving pale tokens difficult to read on light diff backgrounds.

Root cause:
The Chroma style was resolved once as github-dark instead of following the active CLI light or dark mode during rendering.

Fix:
Resolve the registered GitHub light/dark counterpart on every render so runtime theme switches and theme-sweep preview frames use matching syntax colors. Add regression coverage for style selection, live output changes, and source-text preservation.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli -count=1
- go test ./...
2026-08-02 02:29:43 +08:00
SivanCola dd9805c110 Merge pull request #7132 from QingLong-C/fix/anchored-popover-position-fallback
fix(desktop): keep anchored popovers visible while layout settles / 布局未就绪时保持锚定浮层可见
2026-08-02 02:29:25 +08:00
SivanCola fa8b24caa5 fix(evidence): parse go build verification flags
Problem:
The verification classifier searched raw go build arguments for ./..., allowing flag values such as -pkgdir ./... to masquerade as package operands.

Root cause:
Build flags and their values were not separated from package operands.

Fix:
Parse documented Go build flags, accept only safe forms, require ./... among actual package operands, and fail closed for output-affecting or unknown flags. Add regressions for safe -tags forms and flag-value bypasses.

Verification:
- go test ./...
- go vet ./...
- go test -race ./internal/evidence ./internal/agent
- bash scripts/cache-guard.sh
- runtime go build flag checks with unchanged workspace status

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:24:00 +08:00
SivanCola 0600ba6835 test(desktop): cover a late popover anchor
Contract: A popover that renders before its anchor must leave the visible fallback and compute an anchored position without requiring a scroll or resize event.

Coverage: Consume the first scheduled measurement while the anchor is absent, then mount the anchor and assert that bounded frame retries recover the correct position and ready state.

Verification: Anchored popover regression test (9/9), frontend typecheck, and git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:16:22 +08:00
SivanCola 66c1cffb87 fix(desktop): retry initial popover measurement
Problem: The defensive popover fallback could remain at the viewport edge when the first animation-frame measurement ran before its anchor mounted.

Root cause: A missing anchor ended the only guaranteed follow-up measurement, while assigning a ref later does not rerun the positioning effect.

Fix: Retry the initial measurement for at most eight animation frames, then stop. Preserve the last valid position after a successful measurement and clear the tracking ref when the popover closes.

Verification: Anchored popover regression test (9/9), frontend typecheck, and git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:16:13 +08:00
SivanCola 56275e3302 Merge main-v2 into PR #7132
Integration: Bring the popover fallback fix onto the latest main-v2 without rewriting contributor history.

Verification: Focused popover tests, frontend typecheck, full frontend tests, and the production build follow in subsequent validation.
2026-08-02 02:15:05 +08:00
SivanCola 6afdb6805b test(agent): satisfy warning-state static analysis
Split the repeated in-memory fallback claim assertion so staticcheck does not
treat two stateful calls as an identical boolean expression.

Verification:
- golangci-lint run --timeout=5m
- go test -race ./internal/agent
- git diff --check
2026-08-02 02:09:51 +08:00
SivanCola b7a423c38a Merge pull request #7147 from ShiroKSH/fix/grep-gb18030-goroutine-leak
fix(grep): prevent GB18030 truncation goroutine leak
2026-08-02 02:06:51 +08:00
SivanCola 643916d687 fix(agent): harden readiness verifier guidance
Problem:
The readiness recovery summary advertised direct go build commands that can create workspace binaries, appeared on unrelated readiness failures, and duplicated classifier examples in a test-only list.

Root cause:
The summary treated every accepted verifier as equally safe to recommend and was appended without consulting the structured readiness failure. Its drift test did not share the data used to render the summary.

Fix:
Recommend a structured safe subset, classify only all-package go build patterns as non-mutating verification, and inject the summary only when verification evidence is actually missing. Add regression coverage for workspace-output builds, recommendation consistency, and conditional injection.

Verification:
go test ./...
go vet ./...
go test -race ./internal/evidence ./internal/agent
bash scripts/cache-guard.sh
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:06:28 +08:00
SivanCola 165c43fc5b fix(agent): serialize persisted reasoning warning claims
Problem:
Separate agent instances cached the warning state independently and used a fixed temporary file, so concurrent claims could duplicate a warning or overwrite another provider.

Root cause:
The read-check-write sequence was not one cross-process transaction.

Fix:
Serialize each claim with a bounded cross-process file lock, reread the state under that lock, and publish the merged provider set with the existing atomic writer. Persistence failures preserve the historical once-per-session warning behavior.

Verification:
- go vet ./...
- go test -race ./internal/agent
- go test ./...
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:01:50 +08:00
SivanCola a1632cbf37 test(desktop): cover popover fallback recovery
Contract: Anchored popovers stay visible before their first real measurement, become ready only after anchored geometry is available, and retain the last valid position during temporary anchor loss.

Coverage: Extend the existing real-component scroll test with deterministic unavailable-to-ready and ready-to-unavailable interleavings.

Verification: The focused anchored-popover regression passed, TypeScript typechecking passed, and git diff --check passed.

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:00:51 +08:00
SivanCola 58bae90bf5 Merge latest main-v2 into macOS icon fix
Integration:
- Update PR #7123 to main-v2 at 84d3d1ae00.
- Preserve the macOS-only icon safe area and pre-signing replacement unchanged.

Verification:
- go test ./...
- (cd desktop && go test ./...)
- (cd desktop && go test -race .)
- bash -n scripts/desktop-build.sh
- git diff --check
2026-08-02 02:00:50 +08:00
SivanCola c38d95dc63 fix(desktop): preserve anchored popover readiness
Problem: Popovers without an initial measured position were hidden off-screen, while the first PR revision fabricated zero-sized rectangles and could replace a previously valid position with a top-left fallback.

Root cause: Measurement readiness and fallback visibility were represented by the same position state.

Fix: Keep the position null until real elements can be measured, preserve the last valid position during temporary anchor loss, and render an unmeasured popover at the visible edge without marking it ready.

Verification: The focused anchored-popover regression passed, TypeScript typechecking passed, and git diff --check passed.

Co-authored-by: SivanCola <[email protected]>
2026-08-02 02:00:45 +08:00
SivanCola b617a82796 Merge main-v2 into PR #7132
Integration: Bring the contributor branch onto the latest main-v2 without rewriting its published history.

Verification: The merge completed without conflicts; full frontend verification follows.
2026-08-02 01:59:30 +08:00
SivanCola 84d3d1ae00 Merge pull request #7149 from SivanCola/fix/pending-update-recovery
fix(updater): recover abandoned pending transactions / 修复遗留更新事务阻塞
2026-08-02 01:36:30 +08:00
SivanCola 2d7ee8fd57 Merge pull request #7128 from clearnature/fix/desktop-footer-decision-overflow
fix(desktop): scope footer overflow to the decision state to unclip composer menus / 将 footer 溢出收敛到决策态以解除输入框菜单裁剪
2026-08-02 01:34:20 +08:00
SivanCola 11a797b0c1 Merge pull request #7150 from esengine/fix/release-control-goreleaser-clean
fix(release): keep GoReleaser product checkout clean / 保持 GoReleaser 产品检出干净
2026-08-02 00:44:02 +08:00
Yan LiandSivanCola 8bf6277f07 fix(desktop): keep composer menus visible across constrained layouts
Problem:
The scoped footer overflow fix restores composer menus in the normal layout, but a terminal drawer or tall composer can still push a fixed-height menu above the viewport.

Root cause:
The menu capped itself with 50vh instead of the actual space above its composer anchor, and sibling-driven layout changes can move that anchor without resizing it.

Fix:
Preserve decision-only footer scrolling, measure the real anchor space while a menu is open, observe surrounding layout and viewport changes, and cap the menu at the smaller of 360px and that available height. Add DOM geometry coverage plus the scoped-footer guard from the overlapping test PR.

Verification:
- pnpm test
- pnpm build
- pnpm typecheck && pnpm test:typecheck
- go test ./... (desktop module)
- wails build -s -tags webkit2_41
- Browser geometry at 1280x720 with the terminal drawer and a tall composer

Fixes #7127

Co-authored-by: SivanCola <[email protected]>
2026-08-02 00:32:41 +08:00
SivanCola 04984cdfc0 fix(release): keep control checkout outside git status
Problem: The first stable CLI publication failed before GoReleaser could build because the protected release-control checkout appeared as an untracked directory in the immutable product checkout.

Root cause: Preview recovery reused an existing CLI release and never exercised GoReleaser dirty-state validation. A new stable release does, and the nested control-plane checkout made the otherwise clean product tree dirty.

Fix: Add only the exact release-control directory to the product checkout local exclude file, verify the nested checkout is ignored, and fail closed if any other product-tree dirt remains. Resolve the common Git directory absolutely so normal clones and linked worktrees behave consistently.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint v1.7.7 release workflows
- bash scripts/cache-guard.sh
- bash -n scripts/release-workflows.test.sh
- git diff --check
2026-08-02 00:29:59 +08:00
SivanCola 7ff0acbb5b Verify pending cancellation postcondition
Problem: Reconciliation treated a nil exact-cancel result as proof that the pending marker was removed, but the existing cancel API intentionally returns nil when a different target version or creation time is observed.

Root cause: The new reconciler relied on the transition return value without re-checking its public postcondition after releasing the transaction locks.

Fix: Re-read pending-update.json after cancellation, report success only when the marker is absent, and fail closed when the same or a newer transaction remains. The deterministic rewrite test now swaps in a different target version to exercise the inert-success path.

Verification: focused internal/repair and desktop updater/startup/handoff tests passed with and without the race detector; git diff --check passed.
2026-08-02 00:25:40 +08:00
SivanCola 010814e7cc Fix abandoned desktop update recovery
Problem: Desktop updates could leave pending-update.json behind when the macOS handoff helper failed after the transaction was prepared. Later installs then failed permanently with a pending update already exists error.

Root cause: Startup and install entry points did not reconcile abandoned immutable update transactions, and the macOS parent treated a successful process spawn as a completed handoff without confirming that the child had validated the transaction.

Fix: Reconcile prepared or partially published transactions through exact cancel/rollback paths before every startup and install mode, preserve genuinely probationary releases with content-bound evidence, add a ready/proceed macOS handoff handshake, and surface recovery progress in both updater views.

Verification: go test ./...; cd desktop && go test ./...; focused repair and desktop go test -race suites; frontend updater tests, typecheck, CSS checks, production build, and bundle budgets.
2026-08-02 00:21:36 +08:00
ShiroKSH 3ea58e6e76 fix(grep): avoid GB18030 truncation goroutine leak 2026-08-01 17:38:13 +03:00
Q1hangL 1d0305478a fix(agent): break delivery readiness deadlock by exposing verifier whitelist to the model
Delivery mode blocks final answers until a host-recognized verification
command is cited in complete_step, but the whitelist classifier
(bashSegmentIsVerification) is invisible to the model. The two most
intuitive guesses both fail deterministically: inline interpreters
(node -e / python -c) are blocked as opaque mutations, and read-only
inspection pipelines (grep|wc, find) never count as verification.
The model loops until maxFinalReadinessBlocksWithProgress=6 or the turn
is force-interrupted - observed 7 readiness injections in one CLI
session and reproduced locally.

Add evidence.VerificationCommandSummary() and embed it in both the
final-readiness retry message and the opaque-interpreter block message,
so the model can pick a valid verifier (node --check, project test
runners, read-only pipelines into a verifier) on the first retry.

Tests: summary/classifier consistency (anti-drift), deadlock-trap
warnings, read-only pipeline escape hatch, retry-message content.
Verified locally with go1.26.5: gofmt clean, go test ok.
2026-08-01 21:23:41 +08:00
SivanCola 481e6fc497 Merge pull request #7142 from esengine/fix/desktop-legacy-pointer-validation
fix(release): accept immutable legacy desktop pointers / 兼容版本化旧桌面指针
2026-08-01 19:52:21 +08:00
SivanCola b377ec5773 fix(release): accept immutable legacy desktop pointers
Problem:\nPreview recovery rejected the existing public pointer even though its assets use the official immutable version directory.\n\nRoot cause:\nThe fallback validator replaced every legacy Preview asset base with the historical rolling desktop-preview directory. This made version-bound legacy manifests with a null release notes URL impossible to validate.\n\nFix:\nTry the immutable legacy layout first, then retain the rolling directory as a second Preview-only compatibility fallback. Add workflow contract coverage and a null release-notes manifest regression.\n\nVerification:\n- bash scripts/release-workflows.test.sh\n- actionlint v1.7.7 release workflows\n- bash scripts/cache-guard.sh\n- git diff --check
2026-08-01 19:37:35 +08:00
SivanCola ba2bf3ac4c Merge pull request #7140 from esengine/fix/desktop-recovery-manifest-order
Fix Desktop recovery manifest ordering / 修复桌面恢复清单顺序
2026-08-01 19:08:06 +08:00
SivanCola d3d94d15c3 Fix Desktop recovery manifest ordering
Problem:
Desktop Preview recovery compared a rebuilt manifest with the immutable published manifest before adopting the validated published record.

Root cause:
The directory comparison ran before the existing manifest was validated against its payload sizes and digests and copied into the recovery candidate.

Fix:
Validate and adopt the immutable manifest before comparing directories, and lock the ordering with static and behavioral regression coverage.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint for release-desktop.yml
- bash scripts/cache-guard.sh
- bash syntax and git diff --check
2026-08-01 18:52:21 +08:00
SivanCola 6d86f1af31 Merge pull request #7139 from esengine/fix/desktop-recovery-authenticated-payloads
Harden authenticated Desktop release recovery
2026-08-01 18:21:57 +08:00
SivanCola d1385aa47e Harden authenticated Desktop release recovery 2026-08-01 18:07:17 +08:00
QingLong-C deb54cc6b8 fix(desktop): keep anchored popovers visible when layout is not ready
AnchoredPopover's position state starts as null. In some WebKit (Wails)
render timings getBoundingClientRect() returns nothing yet, and the old
'if (!anchor || !menu) return' bailout left position unset forever:
the popover stayed visibility:hidden and parked off-screen at -9999
until a scroll/resize event happened to recompute it - so it only
appeared after the mouse moved over it.

Fall back to a zero-size rect so position is always computed (clamped
to a visible in-viewport spot), and use EDGE_GAP instead of -9999 as
the pre-layout fallback so an unmeasured popover is never hidden.
2026-08-01 15:25:20 +08:00
SivanCola 37d18c872b Harden CLI and Desktop recovery control (#7130) 2026-08-01 14:49:11 +08:00
SivanCola a47909afb3 Preserve valid minisign signatures during recovery
Problem: Rebuilding an immutable Desktop candidate produces byte-distinct minisign files, so recovery rejects an otherwise identical published directory.

Root cause: The directory verifier treated nondeterministic detached signatures as reproducible payload bytes.

Fix: Verify candidate and published signatures with the embedded public key, allow only verified signature-byte differences, and copy existing signatures into the recovery set so immutable objects are preserved.

Verification: Release workflow contracts and Actionlint pass; public Preview Linux and Windows installer assets verify against the embedded key.
2026-08-01 14:34:48 +08:00
SivanCola fb705e02b1 Run CLI recovery policy from control plane
Problem: Historical Preview recovery checks out the immutable product candidate, so recovery scripts added after that tag are absent.

Root cause: The CLI publisher mixed candidate source and publication control policy in one checkout, unlike the Desktop publisher.

Fix: Add a protected workflow-SHA control checkout and run the CLI publication decision from it while keeping product builds pinned to the approved candidate.

Verification: release-workflows.test.sh and Actionlint pass; regression assertions reject candidate-controlled publication policy.
2026-08-01 14:20:38 +08:00
SivanCola 55d924776a Make release recovery idempotent (#7125) 2026-08-01 13:01:32 +08:00
SivanCola 47986edb02 Accept immutable paths in legacy Preview validation 2026-08-01 12:44:00 +08:00
SivanCola 13e772af35 Make CLI release recovery idempotent 2026-08-01 12:40:08 +08:00
LouF666 fffbce9d53 fix(desktop): restore macOS icon safe area 2026-08-01 12:30:25 +08:00
SivanCola 7ded22abdd Merge pull request #7122 from SivanCola/fix/preview-r2-release-tag
Fix orchestrated Desktop mirror tag validation / 修复编排式桌面镜像标签校验
2026-08-01 12:08:55 +08:00
SivanCola 0125c6c4f0 Add explicit Preview release recovery
Problem:
A failed Preview publisher cannot consume a workflow fix by rerunning the original Actions attempt, while the immutable Preview tag must not be moved to the repaired main-v2 commit.

Root cause:
The documented all-surface Preview recovery path still required the Preview tag to equal current main-v2, making recovery of an ancestor candidate impossible.

Fix:
Add an opt-in Preview recovery input, keep the control workflow pinned to protected current main-v2, allow only a historical Preview tag that remains on main-v2 history, and propagate the recovery authorization to the CLI publisher.

Verification:
- bash scripts/release-workflows.test.sh
- bash -n scripts/resolve-preview-release.sh scripts/release-workflows.test.sh
- git diff --check
2026-08-01 11:47:55 +08:00
SivanCola 1ec831e37c Fix orchestrated Desktop mirror tag validation
Problem:
Orchestrated Desktop releases passed an empty direct tag input to the R2 mirror revalidation step, blocking Preview publication and the next Stable mirror.

Root cause:
The mirror job used inputs.tag even though Preview and Stable orchestrators authorize releases through inputs.approved_cli_tag.

Fix:
Revalidate the orchestrator-approved CLI tag in the mirror job and lock the mapping with a workflow contract regression assertion.

Verification:
- bash scripts/release-workflows.test.sh
- git diff --check
2026-08-01 11:45:14 +08:00
SivanCola be16778f70 Merge pull request #7119 from esengine/release-notes/v1.19.0-preview.2
Prepare v1.19.0-preview.2 release notes / 准备 v1.19.0-preview.2 发布说明
2026-08-01 10:04:52 +08:00
SivanCola 569dfd61ae docs(release): prepare 1.19.0 preview 2 notes
Problem:
- The first 1.19.0 Preview tag exposed a Desktop orchestrator authorization failure before any public surface could complete.
- Recovery must use a new immutable Preview ordinal instead of moving the published tag.

Fix:
- Add the reviewed bilingual 1.19.0-preview.2 record for the release control-plane repair in PR #7118.
- Bind CLI, Desktop, and npm identities to Preview ordinal 2.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check
- Release-record privacy scan
2026-08-01 10:04:00 +08:00
SivanCola ea15280399 Merge pull request #7118 from SivanCola/fix/preview-desktop-orchestrator
Fix Preview Desktop orchestrator authorization / 修复预览版 Desktop 编排授权
2026-08-01 10:02:56 +08:00
SivanCola fae6f7e295 fix(release): authorize preview desktop orchestration
Problem:
- The protected Preview orchestrator passed orchestrated=true to the Desktop release workflow.
- The immutable candidate resolver treated every orchestrated call as Stable and rejected every Desktop Preview before signing preflight.

Root cause:
- release-desktop.yml did not forward the trusted orchestrator kind to the resolver.
- The resolver had a hard-coded Stable-only check, and contract tests covered only standalone Preview resolution.

Fix:
- Forward and validate the stable/preview orchestrator identity in every candidate resolution step.
- Permit the Preview orchestrator to authorize its approved SHA, including recovery after main-v2 advances, while retaining ancestry and tag checks.
- Add success, mismatch, and recovery regression coverage.

Verification:
- bash scripts/release-workflows.test.sh
- git diff --check
- Privacy scan of the patch
2026-08-01 09:49:00 +08:00
SivanCola e2fe16161e Merge pull request #7117 from esengine/release-notes/v1.19.0
Prepare v1.19.0 stable release notes / 准备 v1.19.0 稳定版发布说明
2026-08-01 09:37:41 +08:00
SivanCola f207e3c8a2 docs(release): prepare 1.19.0 stable notes
Problem:
- The v1.19.0 stable release needs a reviewed catalog record before stable tags can be pushed.
- The hosted generator returned empty content, and the live fallback produced a schema-invalid change body twice.

Fix:
- Derive the stable record deterministically from the reviewed preview record because v1.18.0 and v1.18.0-preview.2 share the same source SHA.
- Replace only the stable channel wording, prior version, release links, and CLI/Desktop/npm build identities.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check
- Privacy scan of the release record diff
2026-08-01 09:36:54 +08:00
SivanCola 3efb28c945 Merge pull request #7116 from esengine/release-notes/v1.19.0-preview.1
Prepare v1.19.0-preview.1 release notes / 准备 v1.19.0-preview.1 发布说明
2026-08-01 09:34:53 +08:00
SivanCola 48239a7000 docs(release): prepare 1.19.0 preview notes
Problem:
- The v1.19.0-preview.1 release needs an exact reviewed catalog record before tagging.
- The hosted notes generator returned empty content twice.

Fix:
- Generate a bilingual prerelease record from the first-parent range v1.18.0-preview.2..main-v2 using the repository script and a validated DeepSeek model.
- Bind CLI, Desktop, and npm build identities to preview ordinal 1.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check
- Privacy scan of the release record diff
2026-08-01 09:33:35 +08:00
SivanCola ee2ace1dbd Merge pull request #7113 from SivanCola/fix/glm-coding-plan-anthropic-default
Fix GLM Coding Plan Anthropic default / 修复智谱 Coding Plan Anthropic 默认模型
2026-08-01 09:22:31 +08:00
SivanCola 870e518db0 fix(config): use supported GLM Anthropic default
Problem:
The curated GLM Coding Plan Anthropic presets selected glm-5.2[1m] by default, which the live endpoint rejected with model-not-found for a valid Coding Plan credential.

Root cause:
The presets promoted a Claude Code-specific model alias to the generic Anthropic-compatible default instead of using the documented glm-5.2 model identifier.

Fix:
Default both China and global Anthropic-compatible Coding Plan presets to glm-5.2 while retaining the optional alias and the one-million-token context configuration. Keep browser mocks and localized descriptions aligned.

Verification:
- go test ./internal/config
- go test ./...
- pnpm --dir desktop/frontend test:all
- ./scripts/cache-guard.sh
- live GLM Coding Plan Anthropic request through the default provider selection
2026-08-01 09:08:48 +08:00
SivanCola 9bc1048bf0 Merge pull request #7103 from SivanCola/feature/deepseek-anthropic-compat
feat(provider): support DeepSeek Anthropic / 支持 DeepSeek Anthropic 兼容协议
2026-08-01 08:43:18 +08:00
SivanCola 5e972b2ac4 Merge pull request #7094 from Oumuamu4/perf/model-poll-jank-fix
perf(settings): batch model polling and prevent stale writes / 批量模型轮询并防止过期写入
2026-08-01 08:31:41 +08:00
SivanCola b6cd04f02d Merge main-v2 and harden DeepSeek Anthropic integration
Problem:
PR #7103 conflicted with the merged Responses preset, retained raw non-tool reasoning across PostLLMCall hooks, and omitted the Anthropic preset from the Desktop browser/localization contract.

Root cause:
Both protocol presets added adjacent config tests independently; the Anthropic client advertised full reasoning round trips despite replaying only tool-call reasoning; frontend mock and i18n tables were not extended with the backend preset.

Fix:
Merge current main-v2 while retaining both protocol tests, scope raw reasoning retention to tool-call turns, and add the localized Desktop mock, adjacent preset ordering, and regression coverage.

Verification:
- go test -p 1 ./... -count=1
- cd desktop && go test -p 1 ./... -count=1
- go test -race ./internal/provider/anthropic ./internal/agent ./internal/config ./internal/boot -count=1
- go vet ./... (root and desktop)
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend build
- ./scripts/cache-guard.sh
2026-08-01 07:59:48 +08:00
SivanCola 62de2b5c66 Merge pull request #7086 from xianyu-sheng/fix/delivery-diagnostic-readiness
fix(delivery): preserve advisory diagnostics without weakening evidence / 修复诊断建议门禁且保留证据要求
2026-08-01 07:47:16 +08:00
sudoviber 5447d1c5a6 fix(agent): show missing tool-call reasoning warning once per provider
DeepSeek (and some gateways) never attach reasoning_content to tool_calls
turns, so the once-per-session notice re-appeared on every new session and
--resume even though its text promised a single showing (#7059).

Persist the warned-provider set to <reasonix home>/state via a new
missingReasoningWarnState store (atomic temp-file+rename, best-effort:
any IO failure degrades to the historical once-per-session behavior) and
check it in warnMissingToolCallReasoning before emitting. Boot supplies
the shared state dir to both executor and planner options; direct Agent
construction without the option keeps the old scope, so nothing changes
for embedders that do not opt in.

Update the notice text to "shown once" and add unit tests (persistence,
sorting, corrupt-file self-heal, empty-dir no-op) plus an e2e test
covering SetSession and fresh-agent runs over a shared state dir.
2026-08-01 05:56:36 +08:00
SivanCola ff044ad3dd Merge latest main-v2 provider updates
Integration:
Bring PR #7094 onto current main-v2 after the Responses API merge. Preserve both the new Responses provider-kind surface and the provider model-cache/race fixes.

Verification:
Passed root and Desktop tests, vet, and build; frontend full tests, typecheck, and production build; focused Go race and frontend cache/privacy regressions; cache-impact guard; and git diff --check.
2026-08-01 03:40:24 +08:00
SivanCola 66b4a9dfa0 Close remaining delivery classifier gaps
Problem:
Markdown identifiers were treated as commands solely because they were backticked, while acknowledgement phrases could suppress a real follow-up task.

Root cause:
Inline shell parsing accepted every non-empty word, and the task heuristic returned immediately on acknowledgement wording without inspecting surrounding clauses.

Fix:
Require command-shaped tokens independent of Markdown quoting, and preserve tasks before acknowledgements or after explicit transition boundaries. Add focused and end-to-end regressions for both cases.

Verification:
- go test ./internal/agent -count=1
- go test ./... -count=1
- go test -race ./internal/agent -count=1
- go vet ./...
- cd desktop && go test ./... -count=1
- ./scripts/cache-guard.sh
- scripts/check-cache-impact.sh with the live PR body
- git diff --check
2026-08-01 03:29:35 +08:00
SivanCola 3d9e13f543 Merge pull request #7099 from clearnature/dev/clearnature-fixes
feat: add hardened Responses API support / 新增并加固 Responses API 支持
2026-08-01 03:28:02 +08:00
SivanCola 9a03928b72 Merge latest main-v2 before final review
Integration:
Bring PR #7094 onto the current main-v2 baseline after the Remote Workbench CLI bootstrap merge. The base changes do not overlap provider settings, credential locking, or model-cache files.

Verification:
The combined tree will be rechecked with Root and Desktop tests, focused frontend regressions, cache guard, and git diff validation before push.
2026-08-01 03:19:24 +08:00
SivanCola b7f58827cd Merge pull request #7105 from SivanCola/fix/remote-workbench-cli-bootstrap
Fix Remote Workbench CLI provisioning over SSH / 修复远程工作台 SSH CLI 供给
2026-08-01 03:14:12 +08:00
SivanCola d03f82e85d Serialize provider credential catalog updates
Problem:
Credential rotation could interleave with provider catalog compare-and-apply, frontend caches ignored the opaque credential revision, and auto-refresh cooldown keys persisted provider header values.

Root cause:
Credential writes and catalog saves used different lock domains, the cache identity omitted modelCatalogFingerprint, and SettingsPanel serialized the full provider request identity into sessionStorage.

Fix:
Serialize credential-store writes with a shared process and advisory file lock, revalidate a content-derived credential revision before catalog commit, key caches and cooldowns by the opaque fingerprint, and add deterministic interleaving and privacy regressions.

Verification:
Focused Go race tests, root and Desktop test suites, Go vet/build, frontend test/typecheck/build, cache guard, and git diff --check passed.

Co-authored-by: SivanCola <[email protected]>
2026-08-01 03:13:52 +08:00
SivanCola 7445d24892 fix(desktop): prioritize DeepSeek Responses preset
Problem:
- The DeepSeek Responses preset fell back to an English description in Chinese settings and appeared far below the official DeepSeek entry.
- Browser preview data omitted the preset, so local UI checks did not match production.

Root cause:
- The preset had no frontend localization mapping or locale entries.
- Production and mock preset ranking did not give the DeepSeek Responses entry an explicit priority.

Fix:
- Localize the preset description in English, Simplified Chinese, and Traditional Chinese.
- Rank it first among curated presets, making it second overall after DeepSeek Official.
- Mirror the production preset and order in browser mock data with regression coverage.

Verification:
- go test ./...
- cd desktop && go test ./...
- pnpm test
- pnpm test:typecheck
- pnpm build
- ./scripts/cache-guard.sh
- Browser preview verified card order and Chinese copy

Co-authored-by: SivanCola <[email protected]>
2026-08-01 03:05:22 +08:00
SivanCola 7bcb70c347 fix(provider): align DeepSeek Anthropic controls
Problem:
DeepSeek V4 Flash lost its supported low effort on the Anthropic endpoint, legacy effort aliases used the Pro mapping for every model, and caller temperature was silently dropped.

Root cause:
The compatibility path normalized effort without considering DeepSeek's model routing and reused the native Anthropic request shape that intentionally omits sampling controls.

Fix:
Apply Flash/Pro-aware effort normalization including Claude aliases and fallback routing, expose Flash low effort in the preset, preserve temperature only for official DeepSeek Anthropic requests, and update documentation and regression coverage.

Verification:
- go test ./internal/provider/anthropic ./internal/config ./internal/boot -count=1
- go test -race ./internal/provider/anthropic ./internal/config ./internal/boot -count=1
- ./scripts/cache-guard.sh
- go vet ./...
- go test -p 1 ./... -count=1
- cd desktop && go test -p 1 ./... -count=1
2026-08-01 03:01:50 +08:00
SivanCola c78e0a234d Harden delivery intent classification
Problem:
Delivery advisory classification still missed affirmative mutations after negated clauses, treated remote diagnostic verbs as observable work, and allowed ordinary commands to escape the evidence gate.

Root cause:
Negation used a fixed prefix window, advisory verbs overrode question semantics globally, and command detection relied on a narrow exact-string list that drifted from the task heuristic.

Fix:
Scope negation across directive boundaries, classify advisory language per clause, recognize concrete commands and file anchors without confusing prose or email addresses, and share mutation intent with the task heuristic. Add a bilingual classification matrix and end-to-end readiness regressions.

Verification:
- go test ./internal/agent -count=1
- go test ./... -count=1
- go test -race ./internal/agent -count=1
- go vet ./...
- cd desktop && go test ./... -count=1
- ./scripts/cache-guard.sh
- scripts/check-cache-impact.sh with the live PR body
- git diff --check
2026-08-01 02:55:50 +08:00
SivanCola 86661687f1 fix(remote): harden Workbench CLI reuse
Problem:
Desktop lint rejected obsolete SSH factory wrappers. Workbench CLI reuse also required SFTP before checking an exact PATH binary and could return a relative executable path.

Root cause:
The bootstrap initialized managed-storage dependencies before the zero-install reuse path, while probe validation checked only the Build ID. Compatibility wrappers remained after callers migrated to explicit-binary factories.

Fix:
Probe an exact absolute PATH binary before SFTP, defer platform detection until installation, reject non-absolute probe results, remove unused wrappers, and cover SFTP-disabled and relative-PATH regressions.

Verification:
go test -p 1 ./... -count=1
cd desktop && go test ./... -count=1
go test -race ./internal/remote/bootstrap -count=1
cd desktop && go test -race . -run Test\(EnsureWorkbenchCLI\|GoSSHStream\|RemoteSSHTransport\) -count=1
go vet ./... (root and desktop)
Windows desktop test package cross-compile
./scripts/cache-guard.sh
2026-08-01 02:53:30 +08:00
SivanCola 16c111abb2 Merge latest main-v2 before final verification 2026-08-01 02:30:48 +08:00
SivanCola 5d9c4e4392 fix(remote): provision exact Workbench CLI
Problem: Remote Workbench on fresh or cross-platform SSH hosts could exit before initialize and surface only a generic connection-closed error.

Root cause: Desktop launched reasonix from the remote PATH without provisioning an exact protocol-compatible build, while the Go SSH path discarded stderr and session exit failures.

Fix: Probe the complete Workbench Build ID, securely provision a verified platform release into a build-specific remote path, launch that executable explicitly, and preserve classified bootstrap failures. Extend legacy serve bootstrap to support verified cross-platform release uploads.

Verification: go test ./...; cd desktop && go test ./...; frontend remote tests, typecheck, and production build; focused race tests and go vet; cache and protocol guards; Windows and Linux/arm64 cross-compilation.
2026-08-01 02:30:45 +08:00
SivanCola d16f9cbb9c Merge main-v2 into DeepSeek Anthropic compatibility
Context: Synchronize PR #7103 with the latest main-v2 after applying the DeepSeek Anthropic reasoning replay review fixes.

Compatibility: Preserve both the existing DeepSeek Anthropic changes and the latest provider, agent, Desktop, and protocol updates from main-v2 without rewriting branch history.

Verification: The merge completed without conflicts and git diff --cached --check passed; full root, Desktop, race, and cache validation follows on the combined tree.
2026-08-01 02:24:30 +08:00
SivanCola d8c3e34a7e fix(provider): preserve DeepSeek Anthropic tool reasoning
Problem: Historical DeepSeek Anthropic tool-call reasoning could be omitted when a later request no longer declared tools, while the reasoning guide could misstate the model-specific effort scales.

Root cause: Request serialization keyed reasoning replay to the current tool list and thinking mode instead of the historical assistant tool-call turn. The documentation also combined Flash and Pro capabilities.

Fix: Replay unsigned DeepSeek thinking blocks only for historical assistant tool-call turns regardless of the current tool declaration or thinking mode, add focused regression coverage, and document the separate Chat Completions and Anthropic effort scales.

Verification: go test ./internal/provider/anthropic -run TestBuildRequestDeepSeek -count=1 -v; go test ./internal/config -run TestDeepSeek\|TestNormalizeEffortDeepSeek -count=1; git diff --check.
2026-08-01 02:24:18 +08:00
SivanCola 3294f2bac6 Merge pull request #7101 from SivanCola/fix/deepseek-prefix-continuation
fix(openai): continue truncated DeepSeek replies
2026-08-01 02:19:18 +08:00
SivanCola 87e2a39c4b fix(responses): preserve continuation contracts
Problem:
Stateful continuations dropped system instructions, and the DeepSeek Responses preset exposed a provider-wide catalog containing unsupported models.

Root cause:
The continuation path returned before instructions were serialized, while the preset reused a models endpoint broader than Responses API support.

Fix:
Serialize instructions before choosing full-history or previous-response continuation, keep DeepSeek Responses on its curated Flash-only list, and add regressions for both contracts.

Verification:
- go test ./internal/provider/responses ./internal/config -count=1
- go test -race ./internal/provider/responses ./internal/config -count=1
- ./scripts/cache-guard.sh
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go vet ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-01 02:18:37 +08:00
SivanCola 95cbc7c25d Merge pull request #7097 from rixzkiye/feat/acp-supervisor-status
feat(acp): add supervisor status and hard overrides
2026-08-01 02:15:04 +08:00
SivanCola 1ae0947019 Merge pull request #7077 from SivanCola/fix/1.18-regression-hardening
Harden 1.18 session, permission, and routing regressions / 修复 1.18 会话、权限与路由回归
2026-08-01 02:13:12 +08:00
SivanCola a9e93336d1 Key provider catalog fingerprints
Problem:
- CodeQL treated the unkeyed SHA-256 catalog fingerprint as weak hashing of sensitive credential-related data.

Root cause:
- The digest crossed the Wails boundary without a process-private key, even though it contained only provider identity and credential-store metadata.

Fix:
- Generate a process-private random key and compute the opaque catalog token with HMAC-SHA256.

Verification:
- focused provider catalog tests under go test -race
- go test ./... and go build ./... in desktop
- git diff --check
2026-08-01 02:09:42 +08:00
SivanCola 842d30eaea Prevent stale provider catalog writes
Problem:
- A delayed background model refresh could submit a full ProviderView after a newer manual save and silently restore old provider settings.
- Switching settings tabs or rotating a credential did not invalidate the pending write.

Root cause:
- The background effect had no request generation guard.
- SaveProviders serialized writes but had no compare-and-apply contract, so an old snapshot could still win last.

Fix:
- Replace full-provider batch writes with narrow model-catalog updates guarded by an opaque provider and credential fingerprint.
- Compare and apply updates while holding the config edit lock, skipping stale snapshots and rebuilding only after real changes.
- Invalidate outdated frontend completions with an effect generation guard.
- Add deterministic stale-completion, credential-rotation, atomicity, and tab-switch regression coverage.

Verification:
- go test -race focused desktop catalog tests
- go test ./... and go build ./... in desktop
- pnpm test and pnpm build in desktop/frontend
- go test ./...
- ./scripts/cache-guard.sh
- git diff --check
2026-08-01 02:01:41 +08:00
SivanCola e6652fcbb9 Merge latest main-v2 before race fix
Context: bring PR #7094 onto the current integration baseline before applying the stale-write remediation.

Verification: merge completed without conflicts.
2026-08-01 02:01:20 +08:00
SivanCola 05c2875b6f Merge origin/main-v2 into fix/deepseek-prefix-continuation
Resolve the overlapping OpenAI provider changes by preserving both the DeepSeek Beta prefix continuation path and the merged Gemini thought-signature/model-normalization behavior.\n\nVerification:\n- go test ./internal/provider/openai ./internal/agent -count=1\n- go test -race ./internal/provider/openai -count=1\n- GOWORK=off go vet ./internal/provider/openai\n- ./scripts/cache-guard.sh\n- go test ./... -count=1\n- cd desktop && go test ./... -count=1\n- git diff --cached --check
2026-08-01 01:59:44 +08:00
SivanCola 4d111d5aeb merge: sync Gemini thought signature fixes 2026-08-01 01:49:17 +08:00
SivanCola 7f3a277625 Merge pull request #7092 from jackijianxa/main-v2
fix(openai): preserve Gemini thought signatures
2026-08-01 01:46:50 +08:00
SivanCola c9e36d468a test(acp): make sandbox degradation check portable
Problem:
The ACP sandbox degradation test failed on Windows even though the runtime
override path behaved correctly.

Root cause:
Windows intentionally normalizes a config-file `bash = "enforce"` setting to
`off`, so the test never entered the unavailable enforced-sandbox branch it
intended to exercise.

Fix:
Set the effective `enforce` override explicitly in both the degradable and
fail-closed test cases, matching the supervisor command-line path on every OS.

Verification:
- go test ./internal/cli -run 'TestACPSupervisorRuntimeState' -count=1
- go test -race ./internal/cli -run 'TestACPSupervisorRuntimeState' -count=1
- GOOS=windows CGO_ENABLED=0 go test -c ./internal/cli
- go test ./...
- cd desktop && go test ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-01 01:45:51 +08:00
SivanCola bcc258e193 Satisfy advisory classifier lint
Problem: CI rejected the advisory classifier with staticcheck S1008 after the functional fix was pushed.

Root cause: The final boolean branch returned true or false explicitly instead of returning the computed negated-mutation value.

Fix: Return negatedMutation directly without changing classifier behavior.

Verification: go test ./internal/agent -count=1; go vet ./...; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-01 01:44:32 +08:00
SivanCola 65d55df218 fix(openai): continue reasoning-only DeepSeek truncations
Problem:
DeepSeek thinking-mode responses can exhaust max_tokens entirely in reasoning_content before emitting visible text, so the prefix-continuation wrapper stopped without attempting the documented Beta recovery.

Root cause:
The continuation gate required accumulated visible text even when a valid thinking-mode reasoning prefix was available.

Fix:
Allow one bounded Beta continuation when thinking mode has accumulated reasoning_content, including an empty assistant content prefix. Add an SSE regression that verifies the wire payload, endpoint sequence, merged usage, and final response.

Verification:
- go test ./internal/provider/openai ./internal/agent -count=1
- go test -race ./internal/provider/openai -count=1
- GOWORK=off go vet ./internal/provider/openai
- ./scripts/cache-guard.sh
- git diff --check
2026-08-01 01:43:17 +08:00
SivanCola 81410c17d8 Fix negated delivery intent classification
Problem: Common negated install requests and remote application run questions still armed delivery mutation or host-evidence gates, preventing advisory responses from completing.

Root cause: Mutation negation depended on a small list of complete phrases, while generic run/test keywords were treated as host-observable without repository, file, or command context.

Fix: Classify English and Chinese negation around each mutation verb, preserve affirmative mixed-intent clauses, and require concrete anchors before run/test wording overrides advisory questions. Add regressions for the reported install and WPS cases plus local technical-work controls.

Verification: go test ./...; cd desktop && go test ./...; go vet ./...; ./scripts/cache-guard.sh; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-01 01:37:04 +08:00
SivanCola 81e9d3e84e fix(acp): recover interrupted supervisor status
Problem:
Persisted ACP status could restore `running` after a process restart even
though the new runtime owned no active turn, leaving supervisors waiting on
ghost work.

Root cause:
`restoreStatusTelemetry` preserved the process-local running state instead of
converting an interrupted snapshot into a new terminal recovery observation.

Fix:
Always restore sessions idle. When the saved snapshot was running, advance the
status sequence and publish `recovery_paused` with an interrupted paused
outcome while preserving partial usage.

Verification:
- go test ./internal/acp ./internal/cli ./internal/control ./internal/boot
- go test -race ./internal/acp ./internal/cli ./internal/control ./internal/boot
- ./scripts/cache-guard.sh
- go test ./...
- cd desktop && go test ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-01 01:36:00 +08:00
SivanCola fa8b83523a feat(provider): support DeepSeek Anthropic compatibility
Problem: Reasonix treated the official DeepSeek Messages endpoint as a generic Anthropic-compatible service, which misses its unsigned thinking replay, text-only input, and automatic cache contracts.

Fix: keep Chat Completions as the default, add an optional curated Anthropic preset, and scope endpoint-specific reasoning, effort, cache, and vision behavior to official DeepSeek hosts.

Verification: focused provider/config/boot tests, Desktop tests, serial root tests, go vet, cache guard, and diff checks pass. The opt-in live probe reached the endpoint but the inherited local credential returned HTTP 401.
2026-08-01 01:35:30 +08:00
SivanCola 8cef530ace fix: satisfy Responses provider lint
Problem:
CI staticcheck reported a redundant break at the end of a switch case in the Responses SSE loop.

Root cause:
The terminal-event case already exits the switch naturally before the outer terminal guard runs.

Fix:
Remove the redundant break without changing stream behavior.

Verification:
go test ./internal/provider/responses
go vet ./internal/provider/responses
git diff --check
2026-08-01 01:34:01 +08:00
SivanColaandclearnature 9e03a506f6 feat: add hardened Responses API provider
Problem:
Reasonix could not use DeepSeek V4 Flash through the official stateless Responses API, while the contributor branch mixed protocol work with unrelated artifacts and missed tool/reasoning round-trip correctness.

Root cause:
The initial implementation treated Responses events and previous_response_id too loosely, dropped assistant reasoning/content on tool turns, duplicated terminal text, and did not persist context-mode configuration.

Fix:
Add a focused Responses provider with deterministic full-history serialization, DeepSeek reasoning/tool replay, safe stateful-prefix validation and stale-ID fallback, vendor-scoped DashScope headers, terminal-state handling, config round-tripping, and a DeepSeek Flash preset.

Verification:
go test ./internal/provider/responses ./internal/config ./internal/boot
go test -race ./internal/provider/responses ./internal/config ./internal/boot
go test ./...
(cd desktop && go test ./...)
go vet ./...
(cd desktop && go vet ./...)
./scripts/cache-guard.sh

Co-authored-by: clearnature <[email protected]>
2026-08-01 01:34:01 +08:00
SivanCola e92bd36d64 merge: sync terminal drawer and V4 Flash support
# Conflicts:
#	desktop/frontend/src/__tests__/workspace-layout.test.ts
2026-08-01 01:29:47 +08:00
SivanCola 812a1ccab2 Merge current main-v2 before PR publication
Integration:
- Include the terminal drawer layout work that landed during remediation validation.
- Preserve the model polling fixes on the current review base.

Verification:
- Merge completed without conflicts; targeted frontend and Desktop build checks follow.
2026-08-01 01:27:18 +08:00
SivanCola 7169451015 Merge pull request #7072 from HaoyueQin/fix/terminal-layout-7046-7047
fix(desktop): separate terminal drawer from workspace dock, add resize handle and accordion animation / 终端独立为底部抽屉,添加拖拽调整和手风琴动画
2026-08-01 01:26:09 +08:00
SivanCola 4b2bca308a Merge latest main-v2 after remediation checks
Integration:
- Include the main-v2 release-channel and DeepSeek effort updates that landed while PR checks were running.
- Keep the remediated model polling changes tested against the current base.

Verification:
- Merge completed without conflicts; post-merge Desktop and frontend validation follows.
2026-08-01 01:25:16 +08:00
SivanCola 962470ca42 Merge pull request #7102 from SivanCola/feature/deepseek-v4-flash-low-effort
feat(deepseek): support low effort for V4 Flash / 支持 V4 Flash low 推理档位
2026-08-01 01:24:08 +08:00
SivanCola 2b2e7aa035 merge: sync stable preview release split 2026-08-01 01:17:31 +08:00
SivanCola 299c711a42 fix(acp): harden supervisor runtime status
Problem:
The supervisor status extension could block default ACP startup when an OS sandbox backend was unavailable, report stale planner state after session rebuilds, expose non-standard ACP fields, and publish unsanitized status text.

Root cause:
Runtime policy was captured only at session creation, sandbox availability was treated as a startup requirement even for automatic policy, permission reasons were added at the ACP v1 root, and presentation text was reused as public status data.

Fix:
Recompute runtime state from the selected model and profile after rebuilds, distinguish explicit sandbox enforcement from automatic degradation, publish truthful sandbox availability, keep extension data under _meta, and normalize/redact stable status fields.

Verification:
- go test ./internal/acp ./internal/cli
- go test -race ./internal/acp ./internal/cli
- ./scripts/cache-guard.sh
- go test ./...
- cd desktop && go test ./...

Co-authored-by: SivanCola <[email protected]>
2026-08-01 01:14:03 +08:00
SivanCola ccd86fe649 Merge pull request #7076 from SivanCola/feature/separate-stable-preview-releases
Separate Stable and Preview release histories / 拆分稳定版与预览版发布档案
2026-08-01 01:12:44 +08:00
SivanCola f8e4077ab3 fix(deepseek): normalize Flash xhigh effort
Problem:
DeepSeek V4 Flash xhigh selections followed the generic DeepSeek mapping to max instead of the documented high effort.

Root cause:
The model capability registry described selectable levels but had no model-specific compatibility aliases.

Fix:
Add a Flash-specific xhigh-to-high alias and regression coverage for inferred and explicit DeepSeek protocols while preserving Pro xhigh-to-max behavior.

Verification:
- go vet ./...
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- cd desktop/frontend && pnpm build
- ./scripts/check-cache-impact.sh
- ./scripts/cache-guard.sh
2026-08-01 01:09:07 +08:00
SivanCola 87172efd72 Fix model polling cache and rebuild semantics
Problem:
- Settings writes returned before controller rebuilds and broke deferred lease retry.
- Failed model fetches never retried, cache identities collided, and credential changes kept stale entries.
- Background catalog updates rebuilt once per provider.

Root cause:
- The shared config apply path was made fire-and-forget.
- Backoff tracked only a delay without a deadline, and the key used modelsURL instead of modelsUrl.
- Auto-refresh persisted providers individually.

Fix:
- Restore synchronous failure-atomic rebuilds and deferred lease handling.
- Add typed time-based cache identity, backoff, invalidation, and stale-inflight guards.
- Batch provider catalog persistence into one transaction and runtime rebuild.
- Key refresh cooldowns by provider set and omit failed batch entries without JSON nulls.

Verification:
- go test ./... (desktop)
- pnpm test
- pnpm build
- pnpm typecheck
- ./scripts/cache-guard.sh
- git diff --check
2026-08-01 01:08:29 +08:00
SivanColaanddailingtao 53d24ac545 fix(openai): scope Gemini thought signatures
Problem:
Gemini-authored tool calls keep opaque thought signatures in session history. After a same-session switch to another OpenAI-compatible provider, the generic request builder forwarded Gemini-only extra_content.google fields to that provider.

Root cause:
Request serialization checked only whether a stored signature existed; it did not check whether the current endpoint or model spoke Gemini thought-signature semantics.

Fix:
Detect the official Gemini endpoint and Gemini model IDs used through compatible gateways. Emit thought signatures only for those current clients, preserve them in provider-neutral history, and cover official, gateway, and switched-provider request shapes.

Verification:
- go test ./internal/provider/...
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>
Co-authored-by: dailingtao <[email protected]>
2026-08-01 01:03:26 +08:00
SivanCola 3e4ef1d20b merge: absorb merged session hardening 2026-08-01 01:02:43 +08:00
SivanCola 794c062799 Merge main-v2 into model polling performance work
Integration:
- Bring the contributor branch onto the current main-v2 baseline.
- Preserve the model polling performance changes for review remediation.

Verification:
- Git completed the semantic merge without conflicts.
2026-08-01 00:58:12 +08:00
SivanCola 9a8c9fd872 Harden delivery evidence classification
Problem: Advisory diagnostics need to finish without synthetic work, but the initial fix also let read-only technical tasks bypass host-observable evidence and let negated clauses mask later mutation requests.

Root cause: The evidence gate was reduced to the mutation classifier, and mutation negation returned for the whole input instead of the clause it qualified.

Fix: Keep advisory, evidence, and mutation decisions separate; prefer concrete review, test, repository, and file signals; and classify mutation negation per clause. Add classifier and end-to-end readiness regressions for advisory, read-only, and mixed-intent prompts.

Verification: go test ./internal/agent -count=1; go vet ./...; go test ./internal/tool/builtin/ ./internal/boot/ -count=1; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-01 00:57:39 +08:00
SivanCola c232c08ded fix(desktop): harden terminal drawer resizing
Problem:
Narrow workbench windows placed the terminal drawer over the status bar, and persisted terminal heights could collapse chat after the window shrank. The hidden resize separator also remained in the keyboard focus order.

Root cause:
Responsive CSS kept the drawer and separator on their wide-layout grid coordinates, while the rendered height reused the stored preference without clamping it to the current viewport.

Fix:
Keep the responsive drawer and separator in the content column, clamp the rendered height whenever the viewport changes, add keyboard resizing, and remove the closed separator from accessibility navigation. Add focused regression contracts for each boundary.

Verification:
- pnpm test:terminal
- pnpm typecheck
- pnpm check:css
- app chrome, status bar, and layout default contract tests
- browser checks at 800x700 and 1280x480
2026-08-01 00:56:28 +08:00
SivanCola 13fd78c652 feat(deepseek): support low effort for V4 Flash
Problem:
DeepSeek V4 Flash supports low, high, and max reasoning effort, but Reasonix exposed only high and max and normalized low to high.

Root cause:
DeepSeek effort capabilities and provider validation treated depth support as protocol-wide instead of preserving model-specific levels.

Fix:
Add low to the V4 Flash capability, preserve existing Pro and generic DeepSeek behavior, forward low only for V4 Flash or explicitly configured providers, and update desktop guidance.

Verification:
- go test ./internal/config ./internal/provider/openai -count=1
- cd desktop && go test ./... -count=1
- pnpm typecheck
- pnpm build
- ./scripts/check-cache-impact.sh
- ./scripts/cache-guard.sh
2026-08-01 00:52:34 +08:00
SivanCola 21bf1b2f78 test(openai): avoid copying provider lock
Construct the disabled-thinking test client directly so go vet does not flag a copy of the embedded atomic authentication state.

Verification:
- go vet ./...
- go test ./internal/provider/openai -count=1
2026-08-01 00:52:31 +08:00
SivanCola 35daa6d18d Merge pull request #7074 from SivanCola/fix/p0-session-permissions-steer
fix(core): harden session durability, steering, and permissions / 加固会话持久化、转向与权限
2026-08-01 00:50:31 +08:00
SivanCola b729bca21c fix(openai): continue truncated DeepSeek replies
Problem:
DeepSeek text responses that reached the output limit stopped at finish_reason=length even though the official API supports assistant-prefix continuation.

Root cause:
The OpenAI-compatible provider treated every completed stream as terminal and had no bounded path to the DeepSeek Beta chat endpoint.

Fix:
Continue official DeepSeek text replies once with a wire-only assistant prefix, preserve reasoning_content in thinking mode, merge usage across requests, and fall back to the original truncated answer when Beta continuation is unavailable. Keep ordinary request bytes, custom gateways, tool calls, and persisted history unchanged.

Verification:
- go test ./internal/provider/openai -count=1
- go test ./internal/agent ./internal/provider/openai -count=1
- ./scripts/check-cache-impact.sh
- ./scripts/cache-guard.sh
- go test ./... -count=1
- cd desktop && go test ./... -count=1
2026-08-01 00:49:22 +08:00
SivanCola 249ffd7936 fix(release): make npm publication resumable
Problem:
An interrupted Stable or Preview event could publish some or all npm packages before another surface failed. Rerunning the documented orchestrator then failed on immutable versions that already existed, so postflight could not create the release marker.

Root cause:
npm/build.mjs always published every package directly under the public dist-tag and required that tag to equal the recovering version.

Fix:
Record the approved candidate SHA in every package, reuse only matching immutable versions, fill missing packages under a temporary tag, and advance public dist-tags monotonically after the full package set exists. Conflicting provenance fails closed and stale recovery cannot roll a newer channel backward.

Verification:
- node --test npm/publish.test.mjs
- bash scripts/release-workflows.test.sh
- actionlint release workflows
- npm six-platform dry build
- ./scripts/cache-guard.sh
2026-08-01 00:42:36 +08:00
SivanCola 2217cd453c merge: sync latest main-v2 markdown fixes 2026-08-01 00:39:45 +08:00
SivanCola 66aabd1be5 merge: integrate latest main-v2 permission fixes 2026-08-01 00:36:11 +08:00
SivanCola ea45b067d6 merge main-v2 into fix/p0-session-permissions-steer 2026-08-01 00:32:39 +08:00
SivanCola 17b939c4b9 Merge pull request #7073 from SivanCola/feature/remark-math-ast-policy
fix(desktop): move inline math policy to remark AST / 将行内数学策略迁移到 remark AST
2026-08-01 00:27:52 +08:00
SivanCola 883256c0e4 Merge pull request #7078 from SivanCola/fix/permission-auto-guard-consistency-pr
fix(runtime): make guarded execution predictable
2026-08-01 00:25:03 +08:00
SivanCola 9ada1eda91 fix(desktop): requeue rejected steer after turn completion
Problem:
A TurnDone event could reach the frontend before an in-flight TrySteer rejection settled, leaving preserved guidance stranded on the shelf while the session was idle.

Root cause:
The idle queue effect skipped the item while its steer request was marked in flight, but clearing that marker after rejection did not change any effect dependency.

Fix:
Re-arm the idle queue only after a rejected steer settles, so an already-finished turn submits the preserved guidance as the next explicit user turn. Keep ordinary follow-up failures user-controlled to avoid automatic retry loops.

Verification:
The focused 228-case composer suite, frontend production and test typechecks, production build, and bundle budgets passed.
2026-08-01 00:15:04 +08:00
SivanCola bca8455155 fix(core): close late-steer and preview permission gaps
Problem:
Late guidance could be reported as accepted after its target turn stopped consuming input, Desktop profile previews could allow explicit Ask rules without an approver, and concurrent transcript durability lacked a real multi-session reload regression.

Root cause:
Steer intake closed after a separate queue check while host adapters used a void compatibility API, the preview used the legacy nil-approver headless gate, and existing concurrency coverage exercised only the display sidecar.

Fix:
Make final steer admission atomic, route observable hosts through TrySteer, retain rejected Desktop guidance as the next explicit turn, warn on abnormal unapplied guidance, preserve dynamic Bash policy while using an explicit fail-closed preview gate, and verify twelve concurrent Desktop sessions through disk reload.

Verification:
Passed focused race tests, root and Desktop Go suites, Go vet, frontend typechecks and focused tests, frontend production build and bundle budgets, and all eight cache-guard scenarios.
2026-08-01 00:09:21 +08:00
SivanCola b7138151fc fix(runtime): close guarded execution review gaps
Problem:
- The settings-page subagent try path still used the permissive legacy headless gate.
- Permission provenance could name an Ask rule overridden by a session grant.
- npx snapshot-update equals forms were misclassified as verification, and headless migration docs were stale.

Root cause:
- One entrypoint bypassed the explicit headless posture builder.
- Rule provenance rescanned matching rules without checking the candidate decision.
- npx flag normalization and bilingual headless-mode documentation were incomplete.

Fix:
- Pin subagent try runs to fail-closed headless Ask and add an entrypoint regression.
- Validate rule provenance against the actual per-subject decision.
- Reject vitest/jest snapshot update equals forms and document --auto/-y migration without adding settings.

Verification:
- go test -p 1 ./... -count=1
- cd desktop && go test ./... -count=1
- affected package and focused race suites
- Node 22 frontend tests and typecheck
- scripts/cache-guard.sh and scripts/check-cache-impact.sh
- git diff --check
2026-08-01 00:09:15 +08:00
SivanCola 1941b2447d fix(desktop): requeue rejected steer after turn completion
Problem:
A TurnDone event could reach the frontend before an in-flight TrySteer rejection settled, leaving preserved guidance stranded on the shelf while the session was idle.

Root cause:
The idle queue effect skipped the item while its steer request was marked in flight, but clearing that marker after rejection did not change any effect dependency.

Fix:
Re-arm the idle queue only after a rejected steer settles, so an already-finished turn submits the preserved guidance as the next explicit user turn. Keep ordinary follow-up failures user-controlled to avoid automatic retry loops.

Verification:
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm typecheck
- pnpm test:typecheck
- pnpm build
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- focused root and Desktop go test -race coverage
- ./scripts/cache-guard.sh
2026-08-01 00:06:54 +08:00
SivanCola d0900bd387 fix(desktop): preserve authored math semantics
Problem:
AST math classification rejected delimiter padding, Young macros copied their internal KaTeX expansion, and common SI-unit contexts left numeric formulas literal.

Root cause:
The policy classified the parser value without trimming, normalization kept only the rendered Young expansion, and numeric context used a narrow unit alternation.

Fix:
Separate authored and rendered math sources through the AST pipeline, classify a trimmed projection while preserving exact copy source, and recognize structured SI/scientific units.

Verification:
- pnpm exec tsx src/__tests__/math-golden.test.ts (263 passed)
- pnpm exec tsx src/__tests__/message-selection-copy.test.ts (13 passed)
- pnpm typecheck
- pnpm test:typecheck
- pnpm test
- pnpm build
- pnpm check:css
- go test ./...
- cd desktop && go test ./...
2026-08-01 00:06:25 +08:00
SivanCola 413422feee fix(release): enforce unified Preview publication
Problem:
- Preview events for different versions could overlap and let an older npm canary publish last.
- CLI, Desktop, and npm child workflows still exposed standalone public Preview paths that bypassed the all-surface release marker.

Root cause:
- The Preview concurrency key included the version tag, while npm had no channel-level serialization.
- Child workflow dispatch inputs and resolvers treated Preview publication as independently recoverable.

Fix:
- Serialize every Preview event and npm dist-tag publisher, and require public CLI, Desktop, and npm Preview releases to come from release-preview.yml.
- Keep Stable/RC recovery and non-publishing Desktop signing checks available, document unified Preview recovery, and add regression coverage.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint v1.7.7 on all release workflows
- git diff --check
2026-07-31 23:53:55 +08:00
rixzkiye 653e03caf7 feat(acp): add supervisor status and hard overrides 2026-07-31 22:06:48 +07:00
ClaudeHub Dev 4c8001eb16 perf(settings): batch model polling with cache, backoff, and async rebuild
Apply performance fixes for model-list polling jank in Settings:

- F1: single-flight dedup + 60s TTL cache (providerModelCache.ts) around
  FetchProviderModels; invalidate on key change.
- F2: exponential backoff 1s->60s for failed fetches; skip background
  auto-refresh on saveData/slow-2g/2g connections.
- F3: startTransition + useDeferredValue around large model candidate
  lists; content-visibility keeps 400+ model rows cheap.
- F4: 30s sessionStorage cooldown for auto-refresh keyed on provider set,
  breaking the refreshKey <-> SaveProvider loop.
- F5: rebuild runs in a goroutine after config persist (async, non-blocking
  binding response); emit settings:rebuild-{complete,failed} events so the
  UI leaves the saving state on completion.
- F6: FetchAllProviderModels batches all providers in one round-trip
  (errgroup, 4 concurrent, 15s per-provider timeout); SettingsPanel falls
  back to per-provider cached calls on batch failure.

Adds golang.org/x/sync for errgroup (go.mod/go.sum).
2026-07-31 09:58:27 -03:00
jackijianxa 5330ceec8c fix(openai): preserve Gemini thought signatures 2026-07-31 11:55:21 +02:00
xianyu-sheng 4eab88d444 fix(delivery): allow diagnostic conversations to complete without host-observable work
When a user asks a diagnostic/troubleshooting question (e.g. "why
isn't my Zotero plugin working in WPS"), the delivery gate currently
demands host-observable work before the agent may finish — even though
the agent cannot run fixup commands on the user's machine.  Because
deliveryTaskNeedsEvidence delegates to heuristicInputIsTask (whose
keyword set catches nearly every real-world prompt), the gate deems
almost every turn a task and blocks the final answer.  The agent
retries up to maxFinalReadinessBlocksWithProgress (6) times, each
retry producing an identical diagnostic reply, until the turn is
failed — a dead-loop that wastes tokens and frustrates the user.

Root cause: deliveryTaskNeedsEvidence is not gated on whether the task
actually expects a mutation.  Diagnostic/advisory conversations
naturally end when advice is given and should not require observable
work.

Fix: return false from deliveryTaskNeedsEvidence when
deliveryTaskNeedsMutation returns false.  This keeps evidence
enforcement in place for mutation-worded tasks ("fix the crash in
a.go") while letting pure diagnostic conversations complete after the
agent has provided its advice.

Additional: add negation phrases to deliveryTaskNeedsMutation
("不敢重新安装", etc.) so that inputs containing mutation keywords in
an explicitly negative context are still classified as non-mutation.

Tests added:
- TestDeliveryTaskNeedsEvidenceSkipsDiagnosticConversations: verifies
  that six diagnostic inputs (Chinese and English) are classified as
  NOT needing evidence, and that mutation-worded inputs still do.
- TestDeliveryDiagnosticConversationCompletes: end-to-end test showing
  a text-only advice turn (no tool calls) completes without a
  FinalReadinessError when the task is diagnostic.

All 67 existing Delivery/FinalReadiness tests pass unchanged.
2026-07-31 16:19:31 +08:00
HaoyueQin a624b74d03 fix(desktop): separate terminal drawer from workspace dock, add resize handle and accordion animation
Fixes #7046
Fixes #7047
Fixes #7075
2026-07-31 11:25:55 +08:00
SivanColaandipanta 838275732c fix: close integration review gaps
Problem:
The integration branch carried early permission and migrated-session variants that broadened PowerShell rule parsing, omitted prompt restoration for legacy Desktop sessions, and left public permission docs inconsistent with headless behavior and the dynamic Bash opt-in.

Root cause:
Compatibility handling lived in the generic rule parser, prompt-less migrated sessions returned before receiving the deterministic system prompt, and the integrated documentation did not include the final safety contracts.

Fix:
Scope legacy PowerShell expansion to additive deny rules, restore and persist fresh prompts for migrated Desktop sessions, exercise the real local-only steer format, align session expectations, and document fail-closed headless execution plus allow_dynamic_bash.

Verification:
- go test -p 1 ./...
- desktop go test -p 1 ./...
- focused race tests for permission, control, agent, capability, config, instruction, and Desktop session paths
- go vet for root and Desktop
- frontend tests and production build
- site tests and production build
- scripts/cache-guard.sh

Co-authored-by: ipanta <[email protected]>
2026-07-31 08:34:34 +08:00
SivanCola 8a304ecd25 fix(core): close late-steer and preview permission gaps
Problem:

Late guidance could be reported as accepted after its target turn stopped
consuming input, Desktop profile previews could allow explicit Ask rules
without an approver, and concurrent transcript durability lacked a real
multi-session reload regression.

Root cause:

Steer intake closed after a separate queue check while host adapters used a
void compatibility API, the preview used the legacy nil-approver headless
gate, and existing concurrency coverage exercised only the display sidecar.

Fix:

Make final steer admission atomic, route observable hosts through TrySteer,
retain rejected Desktop guidance as the next explicit turn, warn on abnormal
unapplied guidance, use an explicit fail-closed preview gate, and verify
twelve concurrent Desktop sessions through disk reload.

Verification:

Passed focused race tests, root and Desktop Go suites, Go vet, frontend
typechecks and focused tests, frontend production build and bundle budgets,
and all eight cache-guard scenarios.
2026-07-31 08:31:38 +08:00
SivanCola a5374fa2d1 test(desktop): cover rendered latex copy contract
Problem:
The copy implementation from merged PR #7007 had only synthetic KaTeX fixtures and did not exercise Reasonix's real Markdown normalization pipeline.

Root cause:
A clean Git merge could still leave the renderer annotation and selection-copy contracts semantically incompatible.

Fix:
Render normalized Markdown through the production remark and rehype plugins, select the resulting DOM, and assert that copy returns the original pipe and percentage formulas.

Verification:
- pnpm exec tsx src/__tests__/message-selection-copy.test.ts (11 passed)
- pnpm exec tsx src/__tests__/math-golden.test.ts (252 passed)
- pnpm typecheck
- pnpm test:typecheck
2026-07-31 08:28:52 +08:00
SivanCola 22db524964 merge: sync main-v2 into math policy branch
Problem:
The PR base advanced with session recovery changes and the merged LaTeX selection-copy implementation.

Root cause:
The feature branch still targeted an older main-v2 snapshot and could not validate the final renderer-to-copy contract.

Fix:
Merge the latest origin/main-v2 without rewriting feature history.

Verification:
The incoming paths do not overlap the renderer changes; focused and full validation will run on the merged tree.
2026-07-31 08:28:10 +08:00
SivanCola f8531294e4 fix(desktop): preserve math source across rendering
Problem:
Currency amounts wrapped in punctuation could be rendered as math, and KaTeX annotations exposed normalized TeX instead of the user's source.

Root cause:
Currency matching read raw wrapper punctuation, while parser-safety and KaTeX normalization rewrites were destructive before selection-copy could observe the original formula.

Fix:
Normalize only currency boundaries, carry parser-safe inline source through the AST, and restore original TeX into KaTeX annotations and data attributes in both live and export renderers.

Verification:
- pnpm exec tsx src/__tests__/math-golden.test.ts (252 passed)
- pnpm test
- pnpm typecheck
- pnpm test:typecheck
- pnpm build
- pnpm check:css
2026-07-31 08:27:47 +08:00
SivanCola 2ecdd441cc chore: merge latest main-v2 into runtime fix
Context:
The official main-v2 branch advanced after the PR branch was published. Merge the live base without rewriting the already-pushed fork history.

Compatibility:
The upstream LaTeX copy changes touch no files modified by the guarded execution fix, so both histories remain intact.

Verification:
- merge completed without conflicts
- full Go, Desktop, frontend, typecheck, and cache guard suites are rerun before PR publication
2026-07-31 08:19:50 +08:00
SivanCola e90b28e519 fix: remove obsolete StepFun migration helper
Problem:
The PR's lint job failed because the StepFun regional endpoint fix left an unused preset-matching helper behind.

Root cause:
The migration was intentionally converted to a no-op so user-selected regional endpoints remain stable, but its former predicate was no longer referenced.

Fix:
Remove the obsolete helper without changing provider loading or persistence behavior.

Verification:
- go test ./internal/config
- git diff --check
2026-07-31 08:19:07 +08:00
SivanCola c54516a516 fix(release): preserve legacy manifests and exact notes
Problem:
- Existing CLI and Desktop manifests predate exact release-note URLs, RC notes had no supported catalog path, and unpublished exact routes could 404.
- The Preview SignPath call-graph test also retained the old allowlist.

Root cause:
- New manifest validation and byte comparisons did not distinguish legacy persisted objects from newly generated candidates.
- Standalone RCs were treated as public changelog identities, and version routes were generated only after publication markers.

Fix:
- Add strict and legacy validation plus semantic comparators that normalize only the missing release-notes field and preserve immutable Desktop manifests.
- Reuse Stable notes for standalone RCs, generate safe unpublished redirects, and update the SignPath contract test.
- Add focused workflow, manifest, route, and RC regression coverage.

Verification:
- bash scripts/release-workflows.test.sh
- go test ./... and go vet ./...
- desktop go test ./... and go vet ./...
- site npm test and npm run build
- node --test scripts/release-notes.test.mjs
- actionlint with the repository CI allowlist
- live v1.18.0 CLI and Desktop manifest compatibility checks
2026-07-31 08:18:49 +08:00
SivanCola d534de0fa2 Merge pull request #7007 from EMEEEEMMMM/feat/restore-latex-source-on-copy
fix: preserve original LaTeX when copying math / 复制公式时保留原始 LaTeX
2026-07-31 08:17:30 +08:00
SivanCola 1ed116a978 fix(runtime): make guarded execution predictable
Problem:
Permission modes, Auto Guard recovery, late steer handling, and desktop submission paths could disagree about whether work should run, prompt, or stop. Concurrent session display writes and unbounded parallel task batches also exposed reliability gaps.

Root cause:
The affected paths used separate fallback assumptions, lost explicit rule provenance, accepted empty provider input behind visible display text, replayed stale guidance, and lacked serialization or admission bounds at key state boundaries.

Fix:
Unify permission precedence and matched-rule explanations, fail closed for headless Ask, require exact approval for dynamic Bash, preserve legacy PowerShell rules, cancel dismissed Ask turns, reject empty provider input while preserving skill-only turns, classify safe verification commands, recover stale edits automatically, persist late steer as local-only, serialize display sidecar updates, and cap parallel task batches.

Compatibility:
Existing session and wire formats remain readable; legacy Set-Content, Add-Content, and Out-File rules migrate transparently; unrelated Verb-Noun tool rules retain their meaning; provider prompt prefixes and tool schemas are unchanged.

Verification:
- go test -p 1 ./...
- cd desktop && go test ./...
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm typecheck
- git diff --check
2026-07-31 08:16:16 +08:00
SivanCola 216e3ca9fd Merge pull request #6971 from clearnature/dev/clearnature-fixes
fix: harden session recovery and Qwen context limits / 加固会话恢复与 Qwen 上下文限制
2026-07-31 08:09:23 +08:00
SivanCola d53ee14cb3 fix: harden 1.18 regression paths
Problem:
Reasonix 1.18 regressed session recovery, mid-turn steering, permission matching, provider configuration, capability routing, and several Desktop workflows.

Root cause:
State ownership and replay paths were inconsistent across rebuilds, dynamic Bash safety had no explicit advanced override, trusted instruction imports were over-confined, and multiple UI and routing surfaces lacked deduplication or stable interaction boundaries.

Fix:
Preserve tab-local session state and raw replay content, reconcile steer and approval ownership, bound parallel work, harden permission and provider behavior, deduplicate capability routes, and repair the affected Desktop interactions with focused compatibility coverage.

Verification:
- go test -p 1 ./...
- cd desktop && go test -p 1 ./...
- pnpm build
- focused and remaining frontend suites
- ./scripts/cache-guard.sh
2026-07-31 08:08:23 +08:00
SivanCola 57bfd5a3da fix(core): resolve session and permission review gaps
Problem:
Migrated Desktop transcripts without a system message still lost new turns on tab switches. PowerShell compatibility also reinterpreted generic hyphenated tool names, and public docs described the old permissive headless behavior.

Root cause:
The Desktop resume wrapper returned early for prompt-less sessions, while cmdlet inference lived in the global permission parser. Existing coverage exercised direct storage instead of the Desktop resume and switch lifecycle.

Fix:
Restore the deterministic system prompt while preserving the loaded persistence baseline, add a migrate/resume/turn/switch/reload regression, scope legacy cmdlet expansion to additive deny rules, and align CLI and site documentation.

Verification:
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- go vet ./...
- cd desktop && go vet ./...
- focused race tests for migrated sessions, prompt stability, and permission rules
- ./scripts/cache-guard.sh
- pnpm --dir site test
- pnpm --dir site build
2026-07-31 01:27:54 +08:00
SivanCola b13860bb14 Preserve paste recovery across history changes
Problem:
Explicit paste recovery truncated bodies containing a matching end marker, and a controller that adopted newer history could reuse paste IDs already present in that transcript.

Root cause:
The frame parser accepted the first matching end marker without validating the label line count, while the TUI paste allocator only learned historical IDs during initial construction.

Fix:
Select frame boundaries whose recovered body matches the encoded line count, and merge authoritative controller history into the wrap-safe ID allocator before every allocation.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli -count=1
- env -u DEEPSEEK_API_KEY go test ./...
- (cd desktop && go test ./...)
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 01:12:43 +08:00
SivanCola b6da01dbdc feat(release): separate Stable and Preview histories
Context: Stable and Preview need independent reviewed identities while CLI, Desktop, npm, and changelog publication remain one protected release event.

Changes:
- Add a protected Preview orchestrator with a shared ordinal and SignPath preflight.
- Bind reviewed release notes to immutable all-surface publication markers.
- Route downloads and changelog archives by channel with accessible theme-aware navigation.
- Harden release authorization, manifests, tests, and operator documentation.

Compatibility:
- Legacy catalog entries without managed status remain readable and public.
- Existing Stable pointers remain unchanged, and Preview continues mirroring the legacy canary pointer for older Desktop clients.
- No persisted user state or Wails JSON contract changes.

Verification:
- bash scripts/release-workflows.test.sh
- node --test scripts/release-notes.test.mjs
- cd desktop && go test ./cmd/sign ./internal/update
- cd site && npm test
- cd site && npm run build
- actionlint for all release workflows
2026-07-31 01:12:23 +08:00
SivanCola e6e1a9c392 Merge latest main-v2 paste history fixes
Integration: Sync main-v2 at e498c4a7 without rewriting the published PR branch.

Scope: The incoming changes repair pasted-text session history and do not overlap the Markdown math policy.

Verification: desktop/go test ./... passed and the staged merge passes git diff --check.
2026-07-31 01:08:46 +08:00
SivanCola 4883fea736 docs(desktop): align numeric math policy comment
Problem: The classifier documentation still described the pre-review behavior where paired numbers defaulted to math.

Fix: Document the implemented literal-by-default policy, positive mathematical context requirement, and contextual currency repair.

Verification: The comment-only diff passes git diff --check.
2026-07-31 01:07:22 +08:00
SivanCola ba414a342f fix(tui): restore LaTeX from replayed history
Problem:
Selecting rendered math from a resumed session or replayed branch copied only the Unicode display glyph instead of the original LaTeX source.

Root cause:
The copy transcript path marker-rendered live Markdown blocks but reused the already-rendered text for replay bundles, so historical assistant formulas had no source spans.

Fix:
Share replay traversal between visible and clipboard renderers, inject the marker-aware assistant renderer for copy operations, and assign unique prefixes across replayed assistant messages. Add coverage for ordinary and local recovery history.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli -run 'TestSelectedText|TestReplaySections' -count=1
- go test -p 2 ./...
- go vet ./...
- cd desktop && go test ./...
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/message-selection-copy.test.ts
- pnpm build
2026-07-31 01:06:37 +08:00
SivanColaandipanta 0971478bb8 fix(core): harden session durability and unattended execution
Problem:
Session updates could be lost after migration or concurrent tab writes. Late steer text could execute on a later turn, headless runs could silently approve writes, PowerShell deny rules missed cmdlets, and parallel_tasks accepted unbounded batches.

Root cause:
Append durability and display sidecar updates lacked fsync and serialized read-modify-write. Unconsumed steers were persisted as future user turns. Explicit headless Ask inherited an allow-on-nil bootstrap gate. Bare cmdlets were parsed as tool names, and task batch length was not bounded.

Fix:
Fsync append events and lock display sidecar updates. Persist unapplied steers as provider-excluded local history. Fail closed for explicit headless Ask and add -y/--auto. Normalize PowerShell cmdlet rules, cap parallel_tasks at 64, and add migration, compatibility, and concurrency regressions.

Verification:
- ./scripts/cache-guard.sh
- go test ./...
- cd desktop && go test .
- go vet ./...
- cd desktop && go vet .
- focused go test -race coverage for sessions, steering, permissions, and parallel tasks

Co-authored-by: ipanta <[email protected]>
2026-07-31 01:02:10 +08:00
SivanCola 8418c1a014 Merge latest main-v2 before replay copy fix
Integration:
Bring the contributor branch onto the current main-v2 baseline without rewriting its public history.

Resolution:
Accept the latest goal lifecycle, DeepSeek vision, rich-link, session telemetry, and pasted-text fixes. None overlaps the TUI replay-copy implementation being repaired next.

Verification:
The merge completed without conflicts and the integrated tree will be validated together with the focused replay-copy fix.
2026-07-31 00:58:15 +08:00
SivanCola e498c4a796 Merge pull request #7064 from HaoyueQin/fix/pasted-text-expand-state-loss-7051
fix(desktop): preserve pasted text and file ref inline cards across session switches / 修复桌面端切换会话后粘贴文本与文件引用内联卡片状态丢失
2026-07-31 00:56:26 +08:00
SivanCola 1cc9d0135f Merge latest main-v2 control updates
Integration: Sync main-v2 at 8ccf5bb4 after the review fixes without rewriting the published PR branch.

Scope: The incoming change is limited to the Goal control path and does not overlap the Markdown math policy.

Verification: go test ./internal/control passed and the staged merge passes git diff --check.
2026-07-31 00:55:36 +08:00
SivanCola adab967036 fix(desktop): preserve table math and currency context
Problem: Moving all inline math normalization after Markdown parsing let GFM split pipe-bearing formulas into table cells, while numeric-only spans without recognized local cues could turn ordinary prices into KaTeX.

Root cause: GFM table tokenization runs before remark AST transforms, and the numeric policy defaulted ambiguous pairs to math while reading only direct text siblings.

Fix: Protect pipe-bearing inline math at the syntax boundary, default ambiguous numeric pairs to literal text, require positive range, operator, or scientific-unit context for numeric math, and derive multilingual currency context from source positions across inline formatting.

Verification: pnpm test; pnpm typecheck; pnpm test:typecheck; pnpm build; go test ./... in root and desktop; 240 math golden cases; 28 rich-link cases; bundle budgets; git diff --check.
2026-07-31 00:54:20 +08:00
SivanCola 3e3f1f86a8 Prevent paste ID overflow after session resume
Problem: A label-like history entry at the platform integer limit made the resumed paste counter overflow to a negative value.

Root cause: History initialization incremented parsed IDs directly, and paste/image allocation used unchecked counter increments.

Fix: Track historical paste IDs and allocate through a wrap-safe counter that skips identifiers already in use.

Verification: go test ./internal/cli -count=1; go test -race ./internal/cli -count=1; env -u DEEPSEEK_API_KEY go test ./...; go vet ./...; desktop go test ./...; ./scripts/cache-guard.sh.

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:50:40 +08:00
SivanCola 8ccf5bb468 Merge pull request #6961 from erphenheimer/refactor/6958-intercept-output-model
refactor(goal): bind FSM intercept outputs to Goal lifecycle / 让 FSM 拦截输出绑定 Goal 生命周期
2026-07-31 00:49:34 +08:00
SivanCola b0044a5857 Merge main-v2 into remark math policy
Integration: Sync main-v2 at 0c0422c8 into the PR branch without rewriting published history.

Conflict resolution: Preserve RichMarkdownLink from #6976 while retaining the shared reasonixRemarkPlugins chain used by live rendering and session export.

Verification: Conflict markers are absent and the staged merge passes git diff --check; full branch verification follows with the review fixes.
2026-07-31 00:47:39 +08:00
SivanCola 0c0422c877 Merge pull request #6908 from myipanta/fix/deepseek-image-400
fix(provider): gate current DeepSeek images with per-model opt-in / DeepSeek 当前模型默认禁图并支持按模型放行
2026-07-31 00:45:33 +08:00
SivanCola 0eafbbba0c Merge pull request #6995 from HaoyueQin/fix/sidebar-statusbar-session-switch-5766-5335
fix(desktop): persist right-panel & status-bar data across session switches / 修复切换会话后右侧栏和状态栏数据丢失
2026-07-31 00:44:32 +08:00
SivanCola 30ffa07048 fix(desktop): recover content-only pasted history
Problem:
Sessions written before RawContent existed still reopen expanded pasted-text blocks after an upgrade.

Root cause:
History display returned the resolver fallback immediately when RawContent was empty, bypassing legacy pasted-block collapse.

Fix:
Use sanitized Content as the replay source for content-only transcripts, preserve sidecar precedence, and cover the real session save/load path.

Verification:
- focused Desktop history recovery tests
- go test ./...
- cd desktop && go test ./...
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:42:32 +08:00
SivanCola cf2592d9b2 Merge pull request #6976 from wqshan-cn/codex/rich-link-icons
feat(desktop): add accessible rich Markdown links / 为 Markdown 链接添加可访问的富样式
2026-07-31 00:39:06 +08:00
SivanCola 10f63a373d fix(goal): reject outputs from replaced lifecycles
Problem:
An old Goal turn could be admitted correctly, then have its assistant output applied to a replacement Goal when SetGoal ran during the model turn. The stale marker could complete or otherwise advance the new Goal.

Root cause:
The lifecycle epoch guarded continuation input admission, but the subsequent advance() call did not verify that the assistant output still belonged to the Goal that started the turn.

Fix:
Capture the continuation epoch before every Goal-owned real, edited, and subagent turn, carry the admitted epoch across synthetic continuations, and validate it under the goalMachine lock before applying any assistant marker. Add deterministic regressions for both ordinary Goal turns and synthetic continuations replacing the Goal during runner execution.

Verification:
- focused Goal lifecycle regression tests
- ./scripts/cache-guard.sh
- go test -count=1 ./internal/control
- go test -race -count=1 ./internal/control
- go test ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:37:29 +08:00
SivanColaandXingbo Zhao ca8a6a1637 fix(desktop): move inline math policy to remark AST
Problem: Inline dollar classification ran before Markdown parsing, conflating paired numeric math with currency and relying on HTML entities to bypass remark-math.

Root cause: A global regex owned both syntax recognition and Reasonix product policy without AST code boundaries or surrounding-node context.

Fix: Add a shared remark AST policy using unist-util-visit, classify parsed inline math as math, currency, or literal, remove the entity escape path, and share the same plugin chain between live rendering and session export. Adapt the paired-number and one-sided-equality cases from #7054.

Verification: pnpm test; pnpm typecheck; pnpm test:typecheck; pnpm build; git diff --check.

Co-authored-by: Xingbo Zhao <[email protected]>
2026-07-31 00:35:25 +08:00
SivanCola f78500742a Merge latest main-v2 into PR #6971
Integration:
Bring the contributor branch up to main-v2 at d541c17e after adding the paste recovery and Qwen preset compatibility fixes. The merge completed without conflicts and preserves the PR-specific behavior.

Verification:
env -u DEEPSEEK_API_KEY go test ./...
(cd desktop && go test ./...)
go vet ./...
go test -race ./internal/agent -run 'Test.*(Snapshot|SaveRewrite|Foreign|Interrupted|Concurrent)' -count=1
./scripts/cache-guard.sh
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:32:53 +08:00
SivanCola 1f8267b254 Preserve recovered paste data and migrate Qwen presets
Problem:
Orphaned paste recovery removed significant whitespace and silently chose the newest conflicting legacy label. Existing Qwen preset installs also kept zero context limits after the curated defaults changed.

Root cause:
The paste parser trimmed framed bodies and stopped at the first history match, while the Qwen preset update had no load-time compatibility migration.

Fix:
Recover only a unique user-authored paste body, preserve its bytes inside the framing newlines, and migrate untouched official Qwen installs while retaining customized provider and model values.

Verification:
go test ./internal/cli ./internal/config -count=1
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:29:59 +08:00
SivanCola 7f2f912ef4 fix(desktop): preserve semantic raw input for paste replay
Problem:
Compact display text replaced complete raw input for pasted and reference turns, degrading capability routing and previous-client replay.

Root cause:
runRefTurnWithResolverSync reused display as the orchestrator raw value even though raw is also the semantic and persistence contract.

Fix:
Restore complete input as raw, keep sidecar and legacy display recovery, and add routing and compatibility assertions.

Verification:
- go test ./...
- cd desktop && go test ./...
- ./scripts/cache-guard.sh
- frontend edit replay tests (17/17)
- frontend pasted block tests (9/9)

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:27:48 +08:00
SivanCola ca03d4c2ec Merge latest main-v2 into rich link work
Integration: Bring PR #6976 forward to main-v2 at d541c17e without rewriting the contributor branch history.

Compatibility: Preserve the session rebind and overlapping-interface fixes introduced after the prior synchronization; the rich-link implementation remains isolated to Markdown presentation.

Verification: Rerun focused frontend tests, type checking, CSS validation, and production build after this merge.
2026-07-31 00:27:32 +08:00
SivanCola 471a2e66be Merge main-v2 into rich link work
Integration: Synchronize PR #6976 with main-v2 at 0ac122a3 while preserving the contributor history.

Conflict resolution: Keep main-v2 terminal, goal, bundle-budget, Markdown bare-rendering, and performance changes; add the rich-link rendering test to the current frontend test list.

Verification: rich-link, selection-copy, Mermaid rendering, and render-optimization tests pass; TypeScript, CSS checks, production build, bundle budgets, and narrow-width browser behavior pass.
2026-07-31 00:27:22 +08:00
SivanCola 295f1a459f Merge latest main-v2 into LaTeX copy branch
Integration:
Bring the contributor branch onto the current main-v2 baseline without rewriting its public commit.

Resolution:
Accept the latest Desktop session reattach and status-bar layout fixes from main-v2; they do not overlap the LaTeX clipboard implementation.

Verification:
The merge completed cleanly. Focused clipboard tests and repository checks are rerun on the integrated tree.

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:26:14 +08:00
SivanCola f5684e24d0 fix: satisfy staticcheck in copy transcript parser
Problem:
CI reported SA4006 because the no-marker branch assigned the loop position immediately before breaking.

Fix:
Remove the dead assignment while preserving the branch's existing output and control flow.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli -run 'TestSelectedText|TestLanguageCommandRefreshesCurrentController|TestCurrencyCommandPersistsAndRefreshesCurrentController' -count=1
- go vet ./internal/cli
- golangci-lint v2.12.2: 0 issues

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:25:40 +08:00
SivanCola d541c17ee7 Merge pull request #7069 from HaoyueQin/fix/issue-7030-interface-overlap
fix(desktop): prevent decision cards from overflowing behind the status bar / 修复决策卡片溢出被底部信息栏遮挡
2026-07-31 00:23:14 +08:00
SivanCola 043aa3b2a1 Harden rich Markdown link rendering
Problem: Compact GitHub labels replaced selectable URLs, long external links could not wrap, and parser-only tests did not cover rendered accessibility or copy behavior.

Root cause: The renderer swapped the DOM text for a short label and applied white-space: nowrap to every enriched link.

Fix: Keep the original URL as DOM text, expose the compact label through presentation-only CSS, add contextual accessible names, allow long labels to shrink and wrap, and exercise the rendered link contract.

Verification: pnpm exec tsx src/__tests__/github-link-rendering.test.tsx; pnpm typecheck; pnpm check:css; git diff --check.
2026-07-31 00:23:07 +08:00
SivanCola a8498b5f7f Merge pull request #6957 from erphenheimer/fix/6955-session-rebind-attach
fix(desktop): reattach detached runtime when rapidly switching sessions on same tab (#6955)
2026-07-31 00:22:23 +08:00
SivanCola 1001901047 Merge main-v2 into PR #6908
Integration:
Resolve the provider assembly conflict by preserving the latest supported_efforts field together with explicit model-scoped DeepSeek vision provenance.

Verification:
- focused config/provider/boot tests passed
- cache guard passed
- root Go suite passed with the live DeepSeek key unset
- focused Desktop tests passed
- frontend composer image tests passed (15/15)

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:21:43 +08:00
SivanCola be970449c1 Merge latest main-v2 into LaTeX copy branch
Integration:
Bring the contributor branch onto the current main-v2 baseline without rewriting its public commit.

Resolution:
Accept the remote workbench runtime fix from main-v2; it does not overlap the LaTeX clipboard implementation.

Verification:
The merge completed cleanly. Focused clipboard tests and repository checks are rerun on the integrated tree.

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:20:27 +08:00
SivanCola 893879c523 fix(provider): allow explicit DeepSeek vision models
Problem:
The official-endpoint guard treated every current and future DeepSeek model as permanently text-only, so an explicitly configured future multimodal model could never receive images.

Root cause:
Vision capability provenance was discarded during provider assembly, leaving the provider unable to distinguish stale provider-wide vision=true from a deliberate per-model opt-in.

Fix:
Keep official DeepSeek models text-only by default, carry positive model-scoped vision provenance from vision_models or model_overrides into provider assembly, and permit image parts only for that explicit opt-in. Add config, provider-serialization, and boot-assembly regressions.

Verification:
- focused config/provider/boot tests passed
- cache guard passed
- root Go suite passed with the live DeepSeek key unset
- focused Desktop tests passed
- frontend composer image tests passed (15/15)

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:18:53 +08:00
SivanCola 0ac122a3a2 Merge pull request #6992 from Nath-Vikky/agent/fix-workspace-catalog-effort-levels
fix(remote): encode empty effort levels as an array
2026-07-31 00:18:48 +08:00
SivanCola fb7d829996 fix(goal): validate continuations at turn admission
Problem:
A synchronous Notice callback could replace the active Goal after the initial continuation check but before the synthetic turn was composed, pairing an old intercept with the replacement Goal.

Root cause:
Continuation validation and the Goal snapshot used by turn composition were separate operations on opposite sides of externally callable event emission.

Fix:
Revalidate and capture the Goal, AutoResearch task, and delivery scope atomically at synthetic-turn admission. Abort stale continuations and keep admitted turns bound to the captured lifecycle snapshot. Update the merged blocked-signal test to the result-owned intercept model and add a deterministic Notice callback regression test.

Verification:
- go test ./internal/control
- go test -race ./internal/control
- go test ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:18:18 +08:00
SivanCola 412d5cb499 fix: preserve LaTeX copy selection boundaries
Problem:
Desktop copy could discard prose around a single formula, while the TUI reused display coordinates against a separately wrapped raw transcript. The original commit also dropped the /currency dispatcher and discarded the /language rebuild command.

Root cause:
The desktop path performed global string substitution instead of respecting DOM range boundaries. The TUI assumed rendered Unicode and LaTeX source had identical line and column geometry.

Fix:
Reconstruct desktop selections from ordered KaTeX DOM ranges and MathML annotations. Re-render semantic CLI Markdown only when copy is requested, attach zero-width source markers to the identical visible rendition, and map selected display cells back to formula spans. Restore both runtime-setting dispatchers and add focused regressions for partial, mixed, duplicate, and wrapped selections.

Verification:
- go test ./internal/cli -run 'TestSelectedText|TestLanguageCommandRefreshesCurrentController|TestCurrencyCommandPersistsAndRefreshesCurrentController|TestRenderInlineMath|TestCopyToClipboard' -count=1
- go vet ./internal/cli
- pnpm exec tsx src/__tests__/message-selection-copy.test.ts
- pnpm typecheck
- pnpm test:typecheck
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:16:17 +08:00
SivanCola 9a30a2e203 Merge current main-v2 into LaTeX copy branch
Integration:
Bring the contributor branch onto the current main-v2 baseline without rewriting its public commit.

Resolution:
Preserve the stable-section Markdown renderer introduced on main-v2. The clipboard implementation reads KaTeX source from the selected DOM instead of adding a render-time annotation pass.

Verification:
The conflict was resolved semantically; the final merged tree is validated after the focused fix is applied.

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:16:01 +08:00
SivanCola 0bc05786d2 Merge pull request #6967 from zdjmrq/fix/issue-6914-tab-scope-overwrite
fix(desktop): pin BranchMeta scope on new session to prevent project-to-global misclassification / 修复新建项目会话被错误归类为全局工作空间
2026-07-31 00:14:35 +08:00
SivanCola c730ff3f39 fix(desktop): recover legacy pasted-text history
Problem:
Legacy sessions can still reopen expanded pasted-text payloads, and edit replay can resubmit provider-only capability, goal, hook, and resolved-reference context.

Root cause:
History always preferred non-empty RawContent without distinguishing old expanded values from the new compact display format, while SubmitText copied the full provider-facing Content.

Fix:
Prefer persisted display sidecars, collapse legacy expanded paste blocks when no sidecar exists, and sanitize replay text down to user-authored content. Keep display and pagination decisions on the same recovery path and cover session restart plus ref-turn raw persistence.

Verification:
- go test ./...
- (cd desktop && go test ./...)
- ./scripts/cache-guard.sh
- frontend edit-replay and message-pasted-blocks tests

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:12:24 +08:00
SivanCola a57f6896e5 Merge remote-tracking branch 'origin/main-v2' into fix/pr6961-intercept-output-clean 2026-07-31 00:10:55 +08:00
SivanCola 735a6ccb12 fix(desktop): release idle source shared host on reattach
Problem:
Reattaching a detached target over an idle visible source closed the source controller but left its shared plugin-host reference counted. Repeated session switches could accumulate references and keep MCP host processes alive.

Root cause:
applyRuntimeTab replaced the visible tab SharedHostKey before the idle source teardown captured it, so the caller could no longer release the source-owned reference.

Fix:
Transfer the idle source host key with its controller, sink, and lease, then release it after controller shutdown outside App.mu. Keep active-source ownership unchanged and add a regression that verifies the shared-host refcount drops from two to one.

Verification:
- cd desktop && go test . -run Test\(RebindTabToDetachedSessionReleasesIdleSourceSharedHost\|RebindTabToDetachedSessionPreservesRunningSourceRuntime\|CanReclaimRejectsDetachedRuntimeOwner\|RebindTargetLeaseFailureKeepsSourceRuntimeAtomic\|RebindPostLeaseValidationFailureRollsBackCandidate\|BuildTabControllerReattachesDetachedSessionRuntime\) -count=1
- cd desktop && go test -race . -run Test\(RebindTabToDetachedSessionReleasesIdleSourceSharedHost\|RebindTabToDetachedSessionPreservesRunningSourceRuntime\) -count=1
- cd desktop && go test ./...

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:10:31 +08:00
SivanCola cadbf753ac Fix status bar layout edge cases
Problem:
The docked status bar change left an empty row in IM detail views, placed narrow classic terminals over chat, and retained duplicate workbench padding.

Root cause:
Grid rows referenced the always-nonzero dock token instead of the visibility-aware status bar height, while responsive row overrides conflated chrome-visible and chrome-hidden layouts.

Fix:
Use --statusbar-height for dock rows, collapse it through layout--statusbar-hidden, restore separate responsive terminal rows, remove residual workbench padding, add regression contracts, and normalize the CSS formatting introduced by the original edit.

Verification:
- pnpm test
- pnpm check:css
- pnpm typecheck
- Browser computed-style and geometry checks at 800px and 1200px
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:09:08 +08:00
SivanCola 0efe7ca859 Fix session telemetry fallback ownership
Problem: Session hydration could issue duplicate context requests, while persisted last-usage fields could be overwritten by planner or subagent telemetry.

Root cause: The fallback effect raced the authoritative hydration request, and recordUsage updated executor fallback fields for every usage source.

Fix: Keep persisted Last* context scoped to executor usage, remove the competing active-tab fetch, and add compatibility, rebind fallback, and hydration coalescing regression coverage.

Verification: go test ./...; pnpm test:typecheck; tsc --noEmit; focused frontend hydration and context tests.

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:05:43 +08:00
SivanCola dd8df270da Merge pull request #6940 from ikelvingo/fix/minimax-cache-fields
fix(openai): normalize Anthropic-style usage fallbacks / 规范 Anthropic 风格用量回退
2026-07-31 00:05:37 +08:00
SivanCola 1e4e8b5d94 Merge main-v2 into PR 6971 hardening branch
Problem:
- PR 6971 had fallen behind main-v2 and conflicted with the new Kimi K3 preset helper.

Root cause:
- Both branches added provider override helpers at the same insertion point.

Fix:
- Merge current main-v2 and retain both the Kimi K3 and Qwen per-model overrides without rewriting contributor history.

Verification:
- go test ./internal/agent ./internal/cli ./internal/config
- ./scripts/cache-guard.sh
2026-07-31 00:04:14 +08:00
SivanCola 0eab350209 Merge pull request #6960 from erphenheimer/fix/6956-goal-idle-turns
fix(goal): reset idleTurns on [goal:blocked] to prevent premature idle intercept
2026-07-31 00:02:58 +08:00
SivanCola e5a85b89c7 Merge latest main-v2 into PR #6992
Integration:
- Update the contributor branch to the latest main-v2 without changing the catalog fix.
- Preserve the empty effort-level normalization and its end-to-end regression coverage.

Verification:
- go test ./internal/remote/workbench/runtime -run ^TestWorkspaceCatalogEmptyEffortLevelsEncodeAsArray$ -count=1
- go test -race ./internal/remote/workbench/runtime -run ^TestWorkspaceCatalogEmptyEffortLevelsEncodeAsArray$ -count=1
- go test ./internal/remote/protocol -count=1
- REASONIX_RELEASE_CACHE_GUARD=1 go test ./...
- cd desktop && go test ./...
- go vet ./...
- go build ./...
- git diff --check
2026-07-31 00:01:58 +08:00
SivanCola 952200ac1d fix(session): harden PR 6971 recovery contracts
Problem:
- Same-revision snapshot recovery could overwrite an interrupted foreign write.
- Paste recovery mutated loaded history, reused labels after resume, and trusted assistant text.
- Mixed Qwen catalogs inherited a provider-wide 1M context budget.

Root cause:
- Revision equality was treated as ownership without checking the persisted digest.
- Recovery lived in the persistence layer while paste IDs were process-local.
- Qwen context metadata was configured only at provider scope.

Fix:
- Require persisted-state ownership for non-prefix snapshots and preserve foreign bytes.
- Keep loaded history byte-stable, recover labels only on explicit resubmission, and continue IDs from history.
- Add per-model context overrides for smaller Qwen catalog models.

Verification:
- go test ./internal/agent ./internal/cli ./internal/config
- go test -race ./internal/agent -run snapshot recovery cases
- ./scripts/cache-guard.sh
- full repository suite passed except an opt-in live probe using an expired DEEPSEEK_API_KEY; the package passed with that variable unset

Co-authored-by: SivanCola <[email protected]>
2026-07-31 00:01:46 +08:00
SivanCola 1e21861f6d Add new session binding regressions
Contract:
Fresh desktop sessions must persist their scope, workspace root, and topic before controller reconciliation, including when the session directory is not yet discoverable.

Coverage:
- Verify project and global binding metadata at creation.
- Verify fallback reconciliation preserves project scope outside known directories.
- Verify corrupt sidecars fail visibly and rejected bindings leave no session artifacts.
- Verify read-modify-write preserves existing BranchMeta persistence and listing fields.

Verification:
- go test -count=1 . -run 'TestPinNewEmptySessionBranchMeta|TestPinnedSessionMetaKeepsProjectBindingOutsideKnownDirectories|TestPinSessionBranchMeta'
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-30 23:56:37 +08:00
SivanCola ea69d991a5 Harden new session metadata initialization
Problem:
New desktop sessions could continue into controller startup after their workspace binding metadata failed to persist, leaving the original project-to-global reclassification window open.

Root cause:
The pinning helper ignored BranchMeta read and write errors and performed an unlocked read-modify-write at each call site.

Fix:
Serialize the BranchMeta update with the session metadata lock, normalize scope/root values, propagate failures from every new-session call site, and remove any newly created unbound session artifacts before returning.

Verification:
- go test -count=1 . -run 'TestEnsureBlankTab|TestTrashTopicFallbackCreatesUnindexedBlank|TestTransientFallbackIndexesOnFirstUserTurn|TestOpenProjectTab'
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-30 23:56:32 +08:00
SivanCola b47fa90386 fix(goal): bind advance outputs to FSM lifecycle
Problem: PR #6961 returned intercepts from advance(), then wrote them back through a second lock acquisition. A concurrent goal replacement, stop/resume, or newer FSM step could therefore consume stale output from an older lifecycle.

Root cause: goalAdvanceResult carried no identity for the machine state that produced it, and the orchestrator round-tripped the output through interceptMsg.

Fix: remove the pending intercept field, consume the returned intercept directly, and validate a monotonic continuation epoch before starting the synthetic turn. Lifecycle transitions and newer FSM steps invalidate older results.

Verification: go test ./internal/control; go test -race ./internal/control; go test ./...; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-07-30 23:55:53 +08:00
SivanCola c68166dbc2 Merge main-v2 into goal output refactor
Context: Bring PR #6961 onto the current review baseline before changing the goal FSM output contract.

Integration: Preserve current goal snapshot, transactional persistence, raw-input propagation, and large-session fixes while keeping the contributor refactor isolated for follow-up changes.

Verification: Resolve the merge without conflicts and validate the resulting focused PR diff before push.
2026-07-30 23:55:52 +08:00
SivanCola c9d2045732 fix(desktop): preserve active source during session reattach
Problem:
Reattaching a detached target session unconditionally closed the visible source controller, cancelling its active turn and background jobs.

Root cause:
The fast reattach path bypassed the normal rebind active-runtime detachment semantics and retired the source after attaching the target.

Fix:
Atomically move an active source runtime into detachedSessions while attaching the detached target. Only idle source runtimes are closed and released. Add regression coverage for dual-running session switches and detached-owner lease reclaim.

Verification:
- cd desktop && go test . -run Test\(RebindTabToDetachedSessionPreservesRunningSourceRuntime\|CanReclaimRejectsDetachedRuntimeOwner\|RebindTargetLeaseFailureKeepsSourceRuntimeAtomic\|RebindPostLeaseValidationFailureRollsBackCandidate\|BuildTabControllerReattachesDetachedSessionRuntime\) -count=1
- cd desktop && go test -race . -run Test\(RebindTabToDetachedSessionPreservesRunningSourceRuntime\|CanReclaimRejectsDetachedRuntimeOwner\) -count=1
- cd desktop && go test ./...

Co-authored-by: SivanCola <[email protected]>
2026-07-30 23:50:51 +08:00
SivanCola 6e23344c82 test(goal): guard blocked signals from idle intercepts
Problem:
The blocked lifecycle coverage only asserted the final status, so it could not detect an idle reminder replacing the normal continuation prompt.

Root cause:
No test exercised a blocked marker while the idle counter was one turn below its threshold.

Fix:
Add a focused goal-machine regression that seeds the near-threshold state and verifies an explicit blocked signal produces no idle intercept.

Verification:
- go test ./internal/control -run '^TestGoalBlockedSignalDoesNotTriggerIdleIntercept$' -count=1
- go test ./internal/control -run 'Goal' -count=1
- go test -race ./internal/control -run '^TestGoalBlockedSignalDoesNotTriggerIdleIntercept$' -count=1
- go test ./...
2026-07-30 23:48:53 +08:00
SivanCola bf2624862c fix(provider): gate images on official DeepSeek endpoints
Problem:
DeepSeek's official chat API rejects image_url content parts, while the original fallback treated image data URLs as file paths and could copy large base64 payloads into message text.

Root cause:
Endpoint image capability was conflated with the DeepSeek reasoning protocol, and the text fallback assumed image values were filesystem paths instead of resolved data URLs.

Fix:
Disable direct image input for official DeepSeek endpoints at both capability resolution and provider assembly, keep explicit vision support for custom DeepSeek-compatible gateways, remove the text fallback, and add request-shape regressions for user and tool images.

Verification:
- focused config, provider, and controller tests passed
- cache guard passed
- root Go suite passed with the unavailable live credential unset
- desktop image capability and frontend composer image tests passed

Co-authored-by: SivanCola <[email protected]>
2026-07-30 23:45:45 +08:00
SivanCola cd09a41ac2 Fix Anthropic-style usage accounting
Problem:
Compatible gateways that return Anthropic-style usage counters were parsed, but input_tokens was treated as the complete prompt and total_tokens stayed zero. Cache writes also omitted ordinary uncached input from miss accounting.

Root cause:
Anthropic-style usage splits the prompt across input_tokens, cache_creation_input_tokens, and cache_read_input_tokens, while Reasonix expects complete prompt, hit, miss, and total counters.

Fix:
Sum all three input components, derive missing totals, preserve DeepSeek and OpenAI field precedence, replace struct-only assertions with decoded JSON fixtures, and remove the unrelated desktop version change from the PR diff.

Verification:
- go test ./internal/provider/openai -run 'TestNormaliseUsage(AnthropicStyleFallback|DeepSeekShape|MiMoShape)$' -count=1
- go test ./internal/provider/openai ./internal/provider
- go test -p 4 ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-07-30 23:45:40 +08:00
SivanCola ba9c989329 Merge main-v2 into MiniMax usage fix
Update the contributor branch to the latest review baseline so already-merged Windows filename work and the build-version placeholder no longer appear in the PR diff.
2026-07-30 23:44:43 +08:00
HaoyueQin aa27777f82 fix(desktop): prevent sidebar/workspace overlap with the status bar
Move the StatusBar out of the footer and into its own dedicated CSS
Grid row so the sidebar and workspace panel no longer extend behind
the semi-transparent bottom status bar.

- Extract <StatusBar> from the footer and place it as a direct child
  of the .layout grid, wrapped in the existing sidebarImDetailConnection guard.
- Change the layout grid from 2 rows (chrome + content) to 3 rows
  (chrome + content + statusbar): the statusbar row uses
  var(--statusbar-dock-height) so it scales with the interface text size.
- Remove position:fixed from :root[data-theme-style] .statusbar — the
  grid row now keeps it anchored to the viewport bottom.
- Properly place the statusbar in every grid variant:
  normal (row 3), workbench-chrome-hidden (row 2), terminal-open (row 3
  or 4), and in the narrow-viewport media query.
- Hide the statusbar row in creation mode (.app--creation .statusbar)
  since that layout has no bottom bar.
- Drop the --statusbar-height compensation from the sidebar and
  workbench-dock bottom padding — the grid row boundary handles the
  separation, so the extra padding only created dead space.
- Fix :root[data-theme-style] .footer bottom padding (38px → 10px):
  the 38px was reserved for the old fixed statusbar and caused a
  visible gap below the composer.
- Let the workspace-panel-resizer fill the full grid-cell height
  (100% instead of calc(100% - var(--statusbar-height))) since the
  resizer no longer needs to avoid a fixed overlay.
- Update the app-chrome-tabs and workspace-layout tests to match the
  new grid-template-rows declarations.

Fixes #7030
2026-07-30 21:08:24 +08:00
HaoyueQin ca525b02a5 refactor(desktop): merge identical SubmitText branches in history loading
The previous fix made both branches of the RawContent check set
hm.SubmitText to m.Content, making the else-if redundant.
Collapse them into a single else branch for clarity.
2026-07-30 17:30:37 +08:00
HaoyueQin 9565b9e038 fix(desktop): preserve pasted text and file ref inline cards across session switches
#1 Root cause: runRefTurnWithResolverSync passed submitText as raw, causing RawContent to store the expanded text instead of the display text. This made history loading leak BEGIN/END blocks into the bubble. #2 Fix SubmitText assignment: set hm.SubmitText to m.Content so the frontend can parse paste blocks. #3 Fix fallback: wrap StripComposePrefixes with StripReferencedContextPrefix to strip file XML blocks when .display.json is missing.
2026-07-30 16:32:00 +08:00
YHH d40748158c Merge pull request #7060 from esengine/fix/stale-sweep-version-evidence
Take the sweep's age from the whole report, not the version field
2026-07-30 14:39:39 +08:00
yhh 7411f9b16c fix(ci): take the sweep's age from the whole report, not the version field
Validating the sweep against live data before enabling it turned up two ways it
would have aged current reports.

#6563 filled the form's version field with 1.0.0 and named the real build,
v1.17.13, further down in the reproduction steps. 1.0.0 shipped, so the
published-tag check could not catch it, and the report would have been swept as
seventeen minors old. Age now comes from the newest released version mentioned
anywhere in the body: a report cannot be older than the newest build it names.
Filtering to published versions keeps Node versions and window sizes out.

Reading the whole body also let feature requests in, and "does it still
reproduce" is a question only a defect report can answer, so the sweep now
requires the bug label.

The target count is unchanged at 160, but the membership is not: six typo'd
reports and seven feature requests left, and old reports whose only version
evidence was in prose came in.
2026-07-29 23:22:15 -07:00
YHH fad89af12d Merge pull request #7058 from esengine/feat/stale-report-sweep
Ask before closing reports too old to confirm
2026-07-30 14:07:30 +08:00
yhh cd184dabe5 feat(ci): ask before closing reports too old to confirm
65% of the open tracker has never had a reply, and 160 open bug reports name a
release nine or more minors back — old enough that nobody can say whether the
symptom survived. Leaving them open costs the reports that can be acted on;
closing them on age alone would be a guess dressed up as triage.

So: ask, wait 21 days, close only what nobody answered. A close is "not planned"
with reopening invited, and any comment after the ask — from anyone — takes the
issue back out of the sweep. Severity labels (data-loss, security, crash) and
anything a maintainer already replied to never enter it at all, because a bot
must not time out a judgement a human already made.

The version check is against the published tag list, not the version format. A
dry run first targeted six issues reporting "1.7.18", "1.7.13" and "1.7.10":
1.7.x only ever reached 1.7.0, so those are dropped digits from 1.17.x — current
releases that parse cleanly as eleven-minor-old ones. Six current reports would
have been swept as ancient.

Posting stays behind the STALE_SWEEP_ENABLED repository variable, so merging
this cannot start commenting on its own.
2026-07-29 22:49:57 -07:00
YHH 4404a916a3 Merge pull request #7057 from esengine/feat/release-verify-issues
Ask reporters to verify on the release that carries their fix
2026-07-30 13:40:20 +08:00
yhh 88925b5caa feat(ci): ask reporters to verify on the release that carries their fix
A PR writing "Fixes #N" auto-closes its issue on merge. One writing "Refs",
"Related" or "Addresses" does not, so the report stays open waiting for a
follow-up after the release that nobody sends. Auditing the last 400 merged
PRs found 33 open issues referenced by shipped work; only three could be
closed on the evidence, and most of the rest were blocked on nothing but an
unanswered "please re-test".

On every published release this now diffs against the previous tag in the
same series, collects the issues those PRs referenced, and asks each still-open
reporter to verify. It never closes anything — deciding a report is resolved
stays human.

Two traps the audit hit are pinned by tests rather than left to be rediscovered:

- "owner/repo#123" is an upstream reference. Matching it once pulled a
  wailsapp/wails issue number in as if it were ours.
- GitHub's issues API answers for pull requests too, so a consolidation PR
  listing "PR #5576 by @x" reads as a perfectly good open issue. A dry run
  against desktop-v1.18.0 showed 26 of 40 references were pull requests; without
  the check the bot would have asked their authors to re-test.

The PR template now says where to put the keyword, since a "- Fixes #123" list
item is exactly the silent no-op that produced this backlog.
2026-07-29 22:23:57 -07:00
YHH fc42c619d8 Merge pull request #7053 from esengine/fix/cli-colour-profile
Fix true-colour detection on Windows Terminal, unify colour capability
2026-07-30 11:49:25 +08:00
yhh 53e8f729e9 feat(cli): wipe the incoming palette across the frame on /theme
A /theme switch repainted every colour in one frame, which reads as a
glitch rather than a deliberate change. The new palette now travels
across the frame from left to right over roughly 290ms.

Both frames are rendered once when the sweep starts, so the cost is two
renders rather than two per animation step; the transcript is frozen for
the duration, and a switch during a running turn skips the animation
rather than freeze streaming output. No-colour terminals, terminals
under 24 columns, and switches that resolve to the current palette also
fall through to the instant repaint.

Rows are composed to an exact cell count. Slicing on an odd column
cannot split a double-width rune, so a CJK transcript otherwise runs a
row one cell long and the boundary zigzags down the screen.
2026-07-29 20:32:11 -07:00
yhh e1f2310b51 fix(cli): resolve colour support from colorprofile
Windows Terminal advertises 24-bit colour through WT_SESSION and sets
neither COLORTERM nor TERM_PROGRAM, so the hand-rolled check never
matched it and every theme colour rendered as its 256-colour fallback.
The same gap hid kitty, alacritty, ghostty, foot, rio, st and contour,
and never downgraded tmux or screen.

Colour capability was also split across two globals that could disagree:
colorEnabled decided on/off from its own NO_COLOR/isatty rules while
supportsTrueColor separately decided fidelity. Both collapse into one
detected profile, and colorOn/trueColorTerminal derive from it, so
CLICOLOR_FORCE and the cmd.exe Windows API path come along for free.

The mode pill bypassed the theme entirely with raw lipgloss colours,
which meant NO_COLOR never suppressed it; it now goes through the same
accessor as everything else.

Terminal background probing was opt-out: thirteen call sites had to
remember a NoProbe wrapper or they would read stdin while the TUI owned
it. Inverted to opt-in, which removes the wrapper and the footgun.

The hand-chosen 256-colour column stays. Delegating it to the library
was tried and reverted: the quantiser maps #222631 to navy 17 at 34x the
distance of the correct grey, and even a proper Lab nearest-neighbour
flattens the dark diff backgrounds to grey, losing the red/green tint
that carries their meaning.
2026-07-29 20:19:59 -07:00
SivanCola 1986da2661 Merge pull request #7039 from SivanCola/test/windows-acp-prompt-wait
Bound ACP prompt waits on Windows / 限定 Windows ACP 提示等待窗口
2026-07-30 00:56:23 +08:00
SivanCola 690cfe2128 test: bound ACP prompt waits for loaded Windows runners
Problem: the full Windows release sweep timed out two ACP end-to-end prompt scenarios after two seconds, leaving their session lease files locked during cleanup.

Root cause: drainPrompt used a two-second idle limit while exercising the complete ACP server, controller, agent, tool-loop, and transcript-persistence pipeline. Loaded Windows runners can exceed that scheduling window without violating the protocol contract.

Fix: keep the end-to-end helper bounded while extending its idle responsiveness window to five seconds and documenting the covered pipeline.

Verification: go test ./internal/acp -run '^TestE2E(ToolTurnAndPersistence|SessionLoad)$' -count=20; go test -race ./internal/acp -run '^TestE2E(ToolTurnAndPersistence|SessionLoad)$' -count=5; go test ./internal/acp
2026-07-30 00:39:40 +08:00
SivanCola 26d1c423fd Merge pull request #7038 from SivanCola/test/windows-controller-shutdown-wait
Stabilize Windows controller completion waits / 稳定 Windows 控制器完成等待
2026-07-30 00:29:32 +08:00
SivanCola f14b6521e8 test: stabilize controller completion waits on Windows
Problem: Windows release runners can take more than one second to schedule asynchronous controller completion during the full desktop suite, causing Fork or Rewind tests to fail before their semantic assertions and leave lease files locked during cleanup.

Root cause: the shared waitNotRunning test helper used a one-second outer bound that was tighter than loaded Windows runner scheduling latency.

Fix: retain the polling invariant while extending the bounded responsiveness window to five seconds and document why the platform-neutral test needs that allowance.

Verification: go test . -run '^Test(Fork|Rewind)KeepsProjectWorkspacePrompt$' -count=20; go test -race . -run '^Test(Fork|Rewind)KeepsProjectWorkspacePrompt$' -count=5; go test ./...
2026-07-30 00:14:30 +08:00
SivanCola 58c8ff60e8 docs(release): finalize v1.18.0 notes (#7037)
Problem: The existing v1.18.0 catalog covered only the earliest merged work and the automated bilingual generator returned truncated JSON twice.

Fix: Replace the draft with a source-grounded bilingual summary of the full v1.17.21..main-v2 release range, including the Guard automation upgrade note and all major product, reliability, security, and release-channel changes.

Verification: release-notes validate, unit tests, zh/en render, grounded PR reference audit, privacy scan, and git diff --check.
2026-07-29 23:20:12 +08:00
SivanCola ba2c4d31b7 test(config): skip Unix mode assertion on Windows (#7036)
Problem: Windows CI failed because the config-lock registry test required Unix mode 0700 from FileMode.Perm().

Root cause: Windows protects the per-user registry through inherited ACLs and does not represent that ACL through Unix permission bits.

Fix: Skip the Unix-only permission-bit assertion on Windows while retaining the cross-process lock and user-profile registry coverage.

Verification: go test ./internal/config -run 'TestAcquireConfigEditLock|TestLockUserConfigEdits' -count=1; go test ./internal/config; go vet ./internal/config; GOOS=windows GOARCH=amd64 go test -c ./internal/config
2026-07-29 23:12:04 +08:00
SivanCola 44d0b7d82d fix(desktop): prevent stale transcript pixels on Windows / 修复 Windows 会话残影 (#7019)
* fix(desktop): prevent stale transcript pixels on Windows / 修复 Windows 会话残影

* Harden Windows transcript compositor backing

Problem:
- Windows WebView2 can retain stale TurnActions pixels after React removes old chat nodes in Classic and Workbench layouts.
- A broad transcript rule would also alter History and Creation surfaces, while background packs must keep their existing viewport geometry.

Root cause:
- Theme-pack CSS leaves the main scrolling transcript transparent, so WebView2 has no opaque repaint surface when child layers disappear.
- The earlier #6365 repair covered Creation only.

Fix:
- Scope an opaque Canvas-backed compositor surface to the primary Classic and Workbench transcript.
- Recreate background-pack image, pane opacity, task opacity, and safe-area washes on clipped viewport-fixed pseudo-layers without touching History or Creation.
- Expand the source contract to cover selector scope, stacking, viewport geometry, and all theme scene variants.

Verification:
- pnpm exec tsx src/__tests__/turn-actions-rendering.test.ts (9 passed)
- pnpm check:css
- pnpm typecheck
- git diff --check
- Chromium computed-style, scroll-stability, and Windows/non-Windows visual parity checks

* fix(desktop): reveal Windows theme backgrounds

Problem:
Windows theme packs loaded correctly, but their scene images stayed hidden after making Classic and Workbench transcripts opaque for WebView2 repaint correctness.

Root cause:
The image and overlay pseudo-elements retained negative z-index values, so they painted behind the transcript opaque compositor backing.

Fix:
Use nonnegative image and overlay layers, raise only direct transcript children above both layers, and enforce the ordering in the source-contract regression.

Verification:
Focused rendering regression 10/10; CSS syntax and z-index checks; TypeScript; full frontend test and production build; native Win11 Wails build and WebView2 task scroll plus home new-session validation.
2026-07-29 22:35:37 +08:00
Yan Li 6cc578ce5a fix(config): set Qwen context window to 1M matching qwen-code tokenLimits
All Qwen provider presets (qwen-cn, qwen-global, qwen-coding-plan-*)
were missing the ContextWindow field, causing the resolver to fall
back to 128k.  Qwen 3.x models (qwen3.5-plus, qwen3.6-plus,
qwen3.7-plus, qwen3.7-max, qwen3-coder-plus) all have 1M context
windows per Alibaba's API docs and qwen-code's tokenLimits.ts.

Ref: qwen-code packages/core/src/core/tokenLimits.ts
2026-07-29 19:46:05 +08:00
b8c82972f2 Harden Guard repair preview confirmation / 加固 Guard 修复预览确认绑定 (#6973)
* fix(guard): bind repair apply to confirmed preview

* Harden Guard preview confirmation binding

Problem: non-interactive apply-plan could accept a bare --yes, while preview IDs omitted derived-state contents and full pending-update identity.

Root cause: confirmation hashes covered only display previews and were checked once before the plan loop, so state-dependent actions could drift after confirmation.

Fix: require a prior preview ID for non-interactive apply-plan, bind opaque file and transaction state, re-check each action immediately before execution, and preserve unrelated multi-action behavior. Update English and Chinese recovery docs.

Verification: go test ./internal/repair ./cmd/reasonix-guard; go test -race ./internal/repair; go vet ./...; go test ./...; git diff --check.

Co-authored-by: SivanCola <[email protected]>

* Serialize confirmed Guard repair mutations

Problem: preview IDs were rechecked before mutation, but another process could still replace a target during the final rename window or race on last-repair.json, losing or mixing undo records.

Root cause: repair actions lacked a shared transaction lock and per-target cross-process locks, while confirmed snapshot/config bytes were read again after approval and replacement writes could overwrite a concurrent recreation.

Fix: serialize repair bookkeeping before canonical target locks, revalidate confirmed state inside the lock and around renames, publish restored content with create-only atomic writes, bind complete pending-update identity, preserve concurrent writers, and keep undo under the same lock order. Add focused coverage for wait/recheck, rename windows, snapshot bytes, unreadable LKG nodes, transaction serialization, and rollback identity.

Verification: go test ./internal/fileutil ./internal/config ./internal/repair ./cmd/reasonix-guard; go test -race ./internal/fileutil ./internal/repair; go vet ./...; env -u DEEPSEEK_API_KEY go test ./...; Windows test-binary cross-compilation for internal/repair and cmd/reasonix-guard; git diff --check.

Co-authored-by: SivanCola <[email protected]>

* Normalize Windows repair lock test paths

Problem: the Windows CI test waited for a raw backslash path while the lock hook receives the canonical lower-case slash form, so the test timed out before exercising the recheck.

Root cause: the regression test compared platform-specific input text instead of the production lock key representation.

Fix: normalize test hook paths with the same Windows case and separator rules used by repair mutation locks.

Verification: focused repair lock tests pass on macOS; Windows amd64 test-binary compilation passes.

Co-authored-by: SivanCola <[email protected]>

* test(jobs): remove StartForSession race

Keep the worker blocked while assertions inspect its state and protect job fields with the existing mutex. This prevents the race job from observing concurrent completion writes when validating accepted input.

Co-authored-by: PaulDing98 <[email protected]>

* fix(repair): bind target identity into Guard preview confirmation

Problem:
- previewId hashed only exported preview fields and content-based state IDs,
  so identical reasonix.toml content in two project roots produced the same
  confirmation and apply-plan --yes could mutate the unconfirmed project.
- Mutation locks used Abs/Clean only, so symlink path aliases (and macOS
  case variants) took different locks for the same real node.
- rollback_update bound only the pending transaction JSON and skipped target
  locks, so a stable pending update could still roll back an unconfirmed
  live release unit.
- When a target was recreated after quarantine rename, the move was not
  recorded in last-repair, so UndoLastRepair could restore an unrelated prior
  repair.

Root cause:
- Confirmation identity omitted canonical target identity; lock keys and
  rollback binding did not converge on real paths or live release-unit state;
  post-rename failure paths returned before persisting the already-moved node.

Fix:
- Embed an irreversible canonical target identity in per-file state IDs used
  by previewId, while verifying quarantined nodes against the original path
  identity.
- Resolve existing symlink parents and case-fold on Windows/macOS when
  building mutation-lock keys and target identities.
- Bind current release-unit file states into rollback preview state, take
  shared target locks during prepare/rollback, and re-check under those locks.
- Persist the quarantine/move transaction when a concurrent recreate is
  detected so undo restores the confirmed node and retains the rewrite as redo.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- go vet ./internal/repair/

* fix(repair): close symlink, app-bundle, and macOS handoff confirmation gaps

Problem:
- canonicalRepairPath EvalSymlinks'd the full path, so two leaf symlinks
  sharing a referent produced the same previewId, and renaming a confirmed
  symlink leaf changed its identity and made apply fail.
- App-bundle rollback bound only directory kind/mode for the live .app and
  omitted the backup tree, so interior executable drift still accepted an
  old previewId and rolled back.
- macOS self-update held mutation locks only while writing the pending
  transaction; the actual mv/ditto ran in a shell helper that ignored the
  shared flock, racing Guard rollback into mixed bundles.

Root cause:
- Leaf nodes are what Lstat/Rename mutate, but identity resolved through them;
  directory release units need tree digests; handoff and repair used different
  processes without a shared lock holder over the swap window.

Fix:
- Resolve only parent-directory symlinks and reattach the original leaf name
  for mutation locks and preview target identity.
- Bind full tree digests for directory release-unit nodes and include backup
  paths in pending-update preview state.
- Replace the macOS shell handoff with a detached self-subprocess that waits
  for the old PID, then holds LockRepairMutations around the directory swap.
- Export LockRepairMutations/Timeout for desktop handoff and add regressions.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- cd desktop && go test -count=1 -run TestMacUpdate .
- go vet ./internal/repair/ and desktop

* fix(repair): secure updater claims and path case locks

Problem:
The detached macOS updater trusted filesystem paths from command-line arguments, and repair identities folded path case by operating system rather than by the target filesystem.

Root cause:
The handoff only checked that a pending-update file existed before swapping bundles. Path canonicalization assumed every macOS and Windows filesystem was case-insensitive.

Fix:
Persist optional handoff paths and owner identity in the pending transaction, claim them under strict pending and target locks, revalidate the staged bundle before swapping, and remove filesystem paths from child arguments. Detect case sensitivity from the actual macOS directory or Windows per-directory flag before folding lock and preview identities.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- cd desktop && go test -count=1 -run TestMacUpdate .
- cd desktop && go test -race -count=1 -run TestMacUpdate .
- go vet ./internal/repair/
- cd desktop && go vet .
- GOOS=windows GOARCH=amd64 go test -c ./internal/repair

* fix(repair): scope case probe helper by platform

Problem:
The Linux golangci-lint job reported existingRepairPathParent as unused.

Root cause:
The helper lived in a common file while its only callers were excluded from Linux builds by Darwin and Windows build tags.

Fix:
Move the helper into a Darwin-or-Windows platform file so it is compiled exactly where the filesystem case probes use it.

Verification:
- golangci-lint run --timeout=5m
- go test ./internal/repair/ -count=1
- cd desktop && go test -count=1 -run TestMacUpdate .
- GOOS=windows GOARCH=amd64 go test -c ./internal/repair

* fix(repair): preserve verified rollback recovery

Problem:
- File rollback could install backup bytes that changed after preflight validation.
- A failed macOS backup restore still cleared pending recovery state and cleanup material.
- NFC and NFD aliases on APFS produced different repair lock and preview identities.

Root cause:
The staged-copy hash was ignored, macOS rollback cleanup was unconditional, and canonical repair paths handled case aliases without filesystem-aware Unicode normalization.

Fix:
Verify the staged rollback bytes against the transaction hash before mutating live targets. Make macOS rollback compensating and retain pending, backup, and staging state when restoration fails. Normalize repair identities to NFD only on APFS and HFS while preserving distinct names on other filesystems.

Verification:
- go test ./internal/repair/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ -count=1
- go test ./cmd/reasonix-guard/ -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race -count=1 -run TestMacUpdate .
- go vet ./internal/repair/
- cd desktop && go vet .
- golangci-lint run --timeout=5m
- GOOS=windows GOARCH=amd64 go test -c ./internal/repair

* Harden transactional updater handoffs

Problem:
Repair and self-update paths could continue without a pending-state lock, apply a prepared update after its transaction or release unit changed, or restore an unverified macOS bundle tree. Linux also persisted a CLI sibling that transaction validation rejected.

Root cause:
Pending transitions used a best-effort unbounded lock, file updaters locked paths without claiming the exact prepared transaction, and app-bundle handoff/rollback validation did not bind both the staged tree and original backup tree across the rename window.

Fix:
Use bounded fail-closed pending locks and a consistent pending-to-target lock order. Claim Linux and Windows replacements by version, creation identity, platform, and canonical release-unit paths. Bind macOS staging, original, installed, and backup trees; recover rejected handoffs only when the original bundle is still verified; protect backup paths and reject symlink escapes. Add focused concurrency, drift, rollback, compatibility, and cross-platform tests.

Verification:
- go test ./internal/repair/ ./internal/fileutil/ ./cmd/reasonix-guard/ -count=1
- go test -race ./internal/repair/ ./internal/fileutil/ ./cmd/reasonix-guard/ -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./... -count=1
- go vet ./... and cd desktop && go vet ./...
- golangci-lint run --timeout=5m ./...
- Windows amd64/arm64 test-binary compilation for repair, desktop, and update-helper

Co-authored-by: SivanCola <[email protected]>

* Complete fail-closed repair and updater transactions

Problem:
Repair, undo, and self-update entry points could act on state that changed while waiting for locks. File-update completion also needed a crash-safe installed-unit binding, and platform handoffs could execute mutable or incompletely claimed payloads.

Root cause:
Invocation identity was sampled too late or incompletely, mutable pending state doubled as installed state, cleanup paths did not consistently compare ownership, and Windows/macOS handoffs did not bind every source, backup, staging, and release-unit transition.

Fix:
Bind every mutating invocation before lock waits and recheck it under a pending-to-target lock order. Persist create-only installed-state sidecars, preserve recovery material on ambiguity, use atomic no-replace publication, validate complete app and file trees, isolate and hold the verified Windows installer through execution, and make rollback/undo crash-retryable.

Verification:
go test ./internal/repair ./internal/fileutil ./cmd/reasonix-guard -count=1
cd desktop && go test ./... -count=1
Windows amd64 and arm64 update-helper test-binary compilation
gofmt -l internal/repair desktop
git diff --check

Co-authored-by: SivanCola <[email protected]>

* Document fail-closed legacy update recovery

Problem:
Recovery documentation described additive update state as generally backward compatible and a Linux comment still described the obsolete mutable-pending design.

Root cause:
The text was not updated when installed release-unit state moved to a transaction-unique sidecar and legacy destructive actions began requiring explicit ownership proof.

Fix:
Document immutable pending state, crash-safe sidecar recovery, and the distinction between readable legacy transactions and authorization for handoff, healthy commit, or app-bundle rollback.

Verification:
git diff --cached --check
Focused repair and updater tests, full race suites, vet, lint, and cross-platform compilation passed against the merged main-v2 baseline.

Co-authored-by: SivanCola <[email protected]>

* Close updater execution and compensation races

Problem:
- Generic failure-marker writes could let a stale invocation overwrite a newer transaction marker.
- Legacy app rollback could move a symlink backup before rejecting it, and file publish could leave a tampered staged node live.
- Windows self-update could execute a helper that changed after transaction prepare, overwrite a concurrently allocated cache path, or add a portable alias that was absent before.
- macOS updater commands were resolved through PATH.

Root cause:
Mutation ownership was not rebound under the pending lock at every public entry point. Helper validation proved only PE architecture, staged-file verification followed symlinks, and helper cache publication used replace semantics.

Fix:
Rebind generic failure markers after lock acquisition, reject non-directory app backups before mutation, require regular-file staged payloads, compensate failed publication by retaining the rejected node and restoring the verified prior file, and preserve pending recovery state. Bind the Windows helper to the exact prepared release-unit SHA-256, create a unique cache node without replacement, freeze it through process creation, preserve the original portable-alias shape, and use absolute macOS system command paths.

Verification:
- go test ./... -count=1
- go test -race ./internal/repair ./internal/fileutil ./cmd/reasonix-guard -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./... -count=1
- go vet ./... and cd desktop && go vet ./...
- golangci-lint 2.12.2 for root and desktop
- cache guard: 8/8 pass
- Windows amd64/arm64 repair, desktop, and update-helper test-binary compilation
- Linux amd64 repair test-binary compilation

Co-authored-by: SivanCola <[email protected]>

* Tidy desktop updater dependencies

Problem:
The desktop CI go-mod-tidy gate failed because the updater no longer imports github.com/minio/selfupdate, but the module files still retained that dependency and its stale transitive checksums.

Root cause:
The transactional updater implementation replaced the selfupdate package without running the desktop module tidy gate afterward.

Fix:
Remove the unused selfupdate requirement and the transitive checksum entries that are no longer reachable from the desktop module graph.

Verification:
- cd desktop && go mod tidy (idempotent)
- cd desktop && go vet ./...
- focused workspace-rebuild timing tests: 10/10 pass
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Harden prepared repair journals and Windows release-unit signing

Problem:
- Repair mutations could leave ambiguous undo state after a crash between
  rename and last-repair.json publication.
- Concurrent writers could replace a pending journal or cleanup node after
  ownership checks, turning cleanup into deletion of unowned material.
- Windows portable/installer updates did not bind signed payload identity
  through packaging, and Linux release-unit publication did not return
  exact installed-state receipts.

Root cause:
Undo bookkeeping treated last-repair.json as the only durable intent, cleanup
used path-based remove after verification, and Windows packaging trusted
unsigned payload layout without a create-only signed manifest contract.

Fix:
- Introduce create-only pending-repair.json write-ahead journals, promote or
  discard them under the repair lock, and fail closed on ambiguous recovery.
- Displace cleanup targets to unique siblings, re-verify identity, and never
  unlink a pending journal that changed before cleanup.
- Bind Windows signed payload manifests through packaging/signing and require
  exact installed-state receipts for Linux release-unit publication.

Verification:
- go test ./internal/repair -count=1 (pass)
- focused security coverage for prepared intent, pending cleanup ownership,
  legacy healthy/rollback fail-closed, and macOS staging cleanup

Co-authored-by: SivanCola <[email protected]>

* fix(ci): use resolved version for payload manifest

Problem: The Windows payload manifest step referenced a nonexistent ver step output, so actionlint rejected the release workflow.
Root cause: The build job receives the release version from needs.resolve.outputs.version, but the step used steps.ver.outputs.version.
Fix: Bind the signed payload manifest to the resolved release version output.
Verification: actionlint v1.7.7 and scripts/release-workflows.test.sh pass.

* fix(repair): bind prepared create ownership to published Windows modes

Problem:
- Windows CI rejected undo after a crash following snapshot create: the
  restored config file remained because CreatedStateID ownership failed.
- Desktop packaging contract tests still expected steps.ver.outputs.version
  after the release workflow switched to needs.resolve.outputs.version.

Root cause:
- Prepared create intents predicted FileMode 0o600, but Windows Lstat reports
  regular files as 0444/0666. Undo then treated the published file as an
  unowned concurrent write and left it in place.
- The packaging test was not updated with the resolved-version workflow fix.

Fix:
- Predict create-state IDs with platform-published file modes and rebind the
  pending journal to the exact on-disk node after AtomicCreateFile.
- Align the Windows signing packaging contract test with the resolve job
  version output.

Verification:
- go test ./internal/repair -count=1
- go test ./internal/repair -run TestPreparedCreateStateIDMatchesAtomicCreate,TestApplyRepairPlanPersistsMissingSnapshotTargetBeforeCreate
- cd desktop && go test -run TestWindowsReleaseSignsPayloadBeforeRepackaging .

Co-authored-by: SivanCola <[email protected]>

* fix(repair): reject prepared create ownership drift

Problem: A file replaced between AtomicCreateFile and ownership rebinding could be claimed by the repair transaction and removed by undo.
Root cause: The pending journal was rewritten to bind whatever state happened to be present after publication instead of proving it matched the precomputed owned state.
Fix: Keep the write-ahead ownership binding immutable and fail closed when the published path differs; add a regression test that preserves a concurrent replacement through undo.
Verification: focused crash/create tests, go test ./internal/repair, repair/fileutil race tests, Guard tests, go vet, and Windows repair test cross-compile pass.

---------

Co-authored-by: SivanCola <[email protected]>
Co-authored-by: PaulDing98 <[email protected]>
2026-07-29 16:52:28 +08:00
SivanColaandffff2004 ac814a5040 fix(release): complete and harden Stable/Preview channels / 补全并加固正式版与预览版渠道 (#6997)
* complete stable and preview channel loop

* fix(site): harden release-channel fallbacks and version scopes

Problem:
Website CLI Stable fallbacks pointed at GitHub releases/latest asset
URLs, incomplete GitHub releases were turned into 404 download links,
and Desktop/CLI Stable races overwrote every .rxv version chip.

Root cause:
CLI releases set make_latest:false so Latest belongs to Desktop Stable;
releaseAssetMap synthesized missing CLI assets; updateStableVersion was
shared across both product surfaces.

Fix:
- CLI fallback always uses the releases page, never /releases/latest.
- Require all 7 CLI assets before accepting a release (match Worker).
- Scope package-manager .rxv updates to CLI Stable only; desktop
  version chips use data-release-version="desktop".

Verification:
node --test site/src/scripts/release-channels.test.mjs
site/src/scripts/download-link.test.mjs

* fix(desktop): unify update channel controls

Problem: The desktop settings page separated channel selection, update status, and install actions across detached rows.

Root cause: The channel picker and updater state machine were rendered as independent controls, so switching channels did not read as one actionable workflow.

Fix: Merge current version/status, Stable and Preview selection, manual refresh, auto-check guidance, and state-specific download/install actions into one compact settings flow. Add the localized channel auto-check hint for English, Simplified Chinese, and Traditional Chinese.

Verification: pnpm --dir desktop/frontend run typecheck; pnpm --dir desktop/frontend run check:css; pnpm --dir desktop/frontend run test:updater (18 passed); pnpm --dir desktop/frontend run build; git diff --cached --check.

* feat(cli): persist release channel selection

Problem:
CLI users had to repeat the verbose --channel flag, and a plain upgrade always returned to Stable instead of following the user's selected public channel.

Root cause:
The native updater had no user-global channel preference, while the website and docs exposed only one-off flag syntax.

Fix:
Add a user-global [cli].update_channel setting, support upgrade stable|preview as persistent switches, retain --channel as a one-off compatibility override, reject conflicting arguments, and synchronize English/Chinese docs plus website commands.

Verification:
- env -u DEEPSEEK_API_KEY GOCACHE=/private/tmp/reasonix-go-cache go test ./...
- env GOCACHE=/private/tmp/reasonix-go-cache go vet ./internal/cli ./internal/config
- npm test (site)
- npm run build (site)
- git diff --check

* fix(release): keep CLI channel pointers monotonic

Problem: Older Stable recovery runs and out-of-order Preview publishers could overwrite a newer public CLI channel pointer.

Root cause: The release workflow wrote cli/<channel>/latest.json unconditionally and had no channel-scoped serialization or strict version comparison.

Fix: Serialize CLI releases by channel, publish immutable per-tag metadata first, validate the current pointer, and move it only when a strict Stable or Preview version tuple is newer.

Verification: bash scripts/release-workflows.test.sh; bash -n scripts/compare-cli-release-tags.sh scripts/release-workflows.test.sh; git diff --check.

* fix(config): preserve symlinked config files

Problem: Persisting the selected CLI update channel could replace a symlinked user config with a regular file.

Root cause: AtomicWriteFile renamed a temporary file over the symlink path instead of the symlink's final target.

Fix: Resolve complete config symlink chains before atomic writes and route both incremental saves and Config.WriteFile through the shared helper.

Verification: go test ./internal/config; focused writable-target and multi-level symlink regression tests; go test ./...; go vet ./internal/cli ./internal/config.

* fix(release): harden stable and preview end-to-end

Problem:
The Stable/Preview loop still allowed stale or cross-channel release pointers, unsafe Desktop release origins and retries, updater events detached from their checked target, and concurrent config writers that could lose user changes.

Root cause:
Release publication lacked one immutable candidate and strict manifest/recovery contracts; Desktop operations were not bound to request, channel, and version; config locks followed logical paths and several writers loaded stale or malformed snapshots.

Fix:
- authorize Desktop candidates from protected main-v2, separate the current control plane from recovered source commits, validate immutable assets, and make GitHub/R2 publication monotonic and resumable
- bind Desktop checks, downloads, verification, and installs to one request/channel/version state machine with strict size and manifest validation
- validate CLI, Desktop, website, and Worker release data consistently across Stable and Preview
- canonicalize and pin config targets, serialize all affected writers, preserve safe symlinks, and fail closed on malformed configuration

Verification:
- go test -race ./...; go vet ./...; go build ./...
- cd desktop && go test -race ./... && go vet ./... && go build ./...
- frontend typecheck, updater tests (31), CSS checks, and production build
- site tests (44) and production build
- Worker typecheck and tests (74)
- release workflow contracts, actionlint, cache guard, shell syntax, diff check, and Linux/Windows cross-builds

Co-authored-by: ffff2004 <[email protected]>

* fix(config): remove obsolete edit wrappers

Problem:
The release-channel hardening left two private config edit wrappers unreachable, causing the pinned golangci-lint unused check to fail.

Root cause:
Callers now resolve and pin final config targets through the transactional edit path, but the superseded convenience wrappers remained in place.

Fix:
Remove only the two unreferenced wrappers while preserving the resolved-target and timeout helpers used by production code and concurrency tests.

Verification:
- go test ./internal/config
- go vet ./...
- golangci-lint v2.12.2 run --timeout=5m
- git diff --check

* fix(release): satisfy workflow shell lint

Problem:
The final CI lint job passed golangci-lint but failed actionlint because the CLI metadata publisher assigned expected_prerelease without reading it.

Root cause:
Prerelease validation moved into validate-cli-release-manifest.sh, leaving duplicate channel-specific shell assignments in the workflow.

Fix:
Remove the unused assignments and keep prerelease validation centralized in the manifest validator.

Verification:
- actionlint v1.7.7 on all release workflows
- bash scripts/release-workflows.test.sh
- bash -n on release helper scripts
- git diff --check

* fix(release): close desktop channel blockers

Problem:
Desktop Preview-to-Stable switches failed for older Debian Stable builds, updater Wails methods broke older one-argument callers, and config edit locks could split across TMPDIR or macOS case aliases.

Root cause:
The privileged helper rejected every downgrade, strict request binding reused legacy exported method names, and lock identity depended on process-local temp directories without Darwin path folding.

Fix:
Allow only signed public Preview-to-Stable Debian downgrades, preserve legacy updater methods beside strict request-bound APIs, and use a stable hardened per-user lock registry with Darwin alias normalization.

Verification:
Root and Desktop tests, Desktop race, Go vet/build, updater 31/31, site 44/44, Worker 74/74, release workflow contracts, cache guard, actionlint, golangci-lint, frontend typecheck/CSS/build, and focused cross-process/security regressions.

---------

Co-authored-by: ffff2004 <[email protected]>
2026-07-29 15:09:26 +08:00
SivanCola 6c58ef5ef9 Raise test real-spawn timeout to deflake Windows CI (#7026)
Problem: TestLoadNormalizesQuotedNodeEvalHooksPerProject flaked in CI
(test (windows-latest)) — the node -e hook child was killed at the
15.03s timeout on a loaded Windows runner, failing the job.

Root cause: hook_test.go's realSpawnTimeout (15s) feeds the per-hook
Timeout for tests that spawn real child processes. The constant's own
comment says these tests assert behavior, not latency, and node cold
starts can stall for seconds on a loaded machine — 15s just was not
generous enough for a saturated Windows GitHub runner.

Fix: raise realSpawnTimeout to 60s. The ceiling only fires on a genuine
hang, so a larger budget costs nothing when children exit normally.
Tests asserting the timeout path keep their own tight budgets (50-100ms)
and are untouched, so the suite stays fast. All real-spawn tests share
this constant (execution_contract_test.go, windows_batch_test.go,
hook_test.go), so one bump deflakes every call site.

Verification:
- go test ./internal/hook -count=1 passes locally (1.5s, node v22).
- CI on this PR exercises the windows smoke suite, which includes
  internal/hook.
2026-07-29 15:04:39 +08:00
SivanCola 9eb301102e Keep required per-OS checks alive when the path gate skips the test matrix (#7025)
Problem: gating the test matrix at job level means a docs-only pull
request produces no test (ubuntu/macos/windows-latest) check runs at all
— a skipped matrix job reports only the parent job check, never the
per-leg checks the Protect main-v2 ruleset requires. Docs-only PRs then
sit at mergeStateStatus=BLOCKED with the required legs stuck in
"Expected". Confirmed live on a docs-only verification PR.

Fix: the test job now always runs (if: always()), and every step carries
the gate via a job-level RUN_STEPS env. When the changes detector
reports an unrelated diff, all steps skip and each leg reports success
in seconds, so the required per-OS checks exist and pass. When the
changes job itself fails, the legs still start and the empty output
fails open (!= 'false') into the full suite. Required check names are
unchanged; non-required jobs keep job-level skipping.

Verification:
- actionlint v1.7.7 passes; YAML parses cleanly.
- On this PR the full matrix runs (workflow files are not in any skip
  list). The docs-only verification PR will be re-run after merge to
  confirm legs report success and the PR turns mergeable.
2026-07-29 15:04:21 +08:00
SivanCola 770d414e6a Speed up pull request CI: focused race, Windows smoke, push-only installer / 加速 PR CI:聚焦 race、Windows 冒烟、installer 仅主干构建 (#7023)
* Gate CI jobs on changed paths to cut Actions waste

Problem: every pull request runs the full ci.yml matrix (11 jobs across
3 OSes) and the cache-impact guard fires on every PR event, even for
docs-only or otherwise unrelated changes. That is roughly 33k wall-minutes
per month on GitHub-hosted runners — free while the repo is public, but
pure queue time now and a real bill if the repo ever goes private.

Change:
- ci.yml: add a cheap `changes` detection job and gate the heavy jobs on
  it. PRs confined to docs/, site/, release-notes/, workers/ or top-level
  Markdown skip the Go test/race/lint/govulncheck/coverage jobs; PRs that
  cannot affect the desktop module (which imports the root kernel via
  `replace reasonix => ../`) skip the desktop/desktop-macos/desktop-windows
  jobs; the site job only runs for site/ changes. Job-level `if` reports
  skipped, which still satisfies the required status checks (lint, race,
  test) — a workflow-level paths-ignore would leave them pending and block
  merges. Detection fails open, and pushes to main-v2 always run the full
  matrix.
- cache-impact.yml: paths-ignore for clearly unrelated paths so the guard
  stops running on every PR event (~3k runs/month) when the PR can never
  trip it.
- release-desktop.yml: retention-days: 7 on the dist-* and preview-dist
  artifacts (same-run handoffs to the publish/mirror steps); they
  previously kept the 90-day default, accumulating ~17 GB of dead storage.

Verification:
- actionlint v1.7.7 passes on all three modified workflows (same version
  and -ignore flags as the lint job).
- Both modified YAML files parse cleanly.
- Simulated the path filter against 10 representative change sets
  (docs-only, root markdown, site-only, desktop-only, internal, npm-only,
  workers-only, workflow file, nested skill markdown, mixed); every output
  matched the intended gating.

* Speed up pull request CI: focused race, Windows smoke, push-only installer

Problem: after the required checks, merging waits on the slowest ones —
`race` averages ~12 min and `test (windows-latest)` ~6.3 min, while the
(non-required) desktop-windows job runs ~14.5 min, most of it building the
NSIS installer. Every PR pays that cost even though main-v2 re-verifies
everything after merge.

Change:
- race: pull requests run `go test -race` only on the concurrency-heavy
  packages (agent, plugin, jobs, proc, sandbox, filelock, eventwire,
  remote — the same areas the job comment calls out). Pushes to main-v2
  keep the full ./... sweep as the safety net. Check name is unchanged,
  so the required-checks ruleset is unaffected.
- test (windows-latest): pull requests run a smoke suite of the packages
  that actually carry *_windows.go code, plus cmd/. Full ./... sweep stays
  on pushes. The Unix legs are untouched.
- desktop-windows: Install NSIS + installer/portable build steps now run
  only on pushes; PRs stop after go test. Installer packaging is
  packaging validation, and the release pipeline rebuilds it regardless.
- codeql.yml: paths-ignore docs/**, release-notes/**, root *.md on push
  and pull_request (CodeQL checks are not required; the weekly cron keeps
  the full scan cadence).

Expected: required-check wait drops from ~12 min to ~5 min per PR, and
the all-green wait from ~15 min to ~6 min, with no loss of post-merge
coverage.

Verification:
- actionlint v1.7.7 passes on both workflows; YAML parses cleanly.
- `go list` resolves all packages in the race subset (24 pkgs) and the
  Windows smoke suite (37 pkgs).
- This PR's own checks exercise the new PR paths live: the race and
  windows-test jobs run their focused variants because the branch only
  touches workflow files.
2026-07-29 14:17:07 +08:00
SivanCola 36a2dee813 Gate CI jobs on changed paths to cut Actions waste (#7022)
Problem: every pull request runs the full ci.yml matrix (11 jobs across
3 OSes) and the cache-impact guard fires on every PR event, even for
docs-only or otherwise unrelated changes. That is roughly 33k wall-minutes
per month on GitHub-hosted runners — free while the repo is public, but
pure queue time now and a real bill if the repo ever goes private.

Change:
- ci.yml: add a cheap `changes` detection job and gate the heavy jobs on
  it. PRs confined to docs/, site/, release-notes/, workers/ or top-level
  Markdown skip the Go test/race/lint/govulncheck/coverage jobs; PRs that
  cannot affect the desktop module (which imports the root kernel via
  `replace reasonix => ../`) skip the desktop/desktop-macos/desktop-windows
  jobs; the site job only runs for site/ changes. Job-level `if` reports
  skipped, which still satisfies the required status checks (lint, race,
  test) — a workflow-level paths-ignore would leave them pending and block
  merges. Detection fails open, and pushes to main-v2 always run the full
  matrix.
- cache-impact.yml: paths-ignore for clearly unrelated paths so the guard
  stops running on every PR event (~3k runs/month) when the PR can never
  trip it.
- release-desktop.yml: retention-days: 7 on the dist-* and preview-dist
  artifacts (same-run handoffs to the publish/mirror steps); they
  previously kept the 90-day default, accumulating ~17 GB of dead storage.

Verification:
- actionlint v1.7.7 passes on all three modified workflows (same version
  and -ignore flags as the lint job).
- Both modified YAML files parse cleanly.
- Simulated the path filter against 10 representative change sets
  (docs-only, root markdown, site-only, desktop-only, internal, npm-only,
  workers-only, workflow file, nested skill markdown, mixed); every output
  matched the intended gating.
2026-07-29 13:52:29 +08:00
SivanCola c53448e02f Fix initial Goal Skill invocations / 修复目标模式首条消息无法调用 Skill (#6991)
* fix(desktop): allow skills in initial goal turns

Problem: Goal mode rejected or swallowed Skill invocations in the first user turn.

Root cause: The Composer blocked structured invocations before a Goal existed, while App wrapped the remaining first turn as /goal prose.

Fix: Require task text but preserve selected and pasted Skill invocations as structured submissions after activating the Goal. Extract the activation/submission order into a tested contract and add coverage for inline and subagent-compatible invocation paths.

Verification: pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx; pnpm exec tsx src/__tests__/send-failed.test.ts; pnpm typecheck; pnpm exec tsc --noEmit -p tsconfig.test.json; ./scripts/cache-guard.sh

* fix(desktop): scope initial Goal activation to source tab

Problem: Switching tabs while the first Goal was activating could store the Goal on the newly active tab while submitting the structured Skill to the original tab. Initial Goal setup also rejected attachment- or workspace-reference-only prompts after the Skill guard changed.

Root cause: The activation callback closed over activeTabId, and the Composer guard treated every empty-text first Goal as invalid even when non-text context was present.

Fix: Add explicit tab-scoped Goal/profile activation, route the initial structured submission through the source tab, preserve attachment and workspace-reference-only submissions, and cover the active-Goal Subagent path plus source-tab wiring.

Verification: go test ./internal/control -count=1; go test -race ./internal/control -count=1; go test -race ./internal/jobs -run TestStartForSession_AcceptsValidInput -count=1; pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx; pnpm exec tsx src/__tests__/send-failed.test.ts; pnpm typecheck; ./scripts/cache-guard.sh; git diff --check.

Co-authored-by: SivanCola <[email protected]>

* fix(desktop): fail closed when initial Goal activation fails

Problem: Structured Skill submission on the first Goal turn could run even when SetGoalForTab failed or no-oped, because activation errors were swallowed and the structured path has no /goal prose fallback.

Root cause: setGoalForTab used .catch(() => {}), applyGoalForTab patched the local Goal profile before backend success, and regression coverage only asserted source wiring instead of deferred/bridge-failure behavior.

Fix: Propagate SetGoalForTab failures (still refresh meta in finally), patch the composer Goal profile only after backend activation succeeds, and document that activateGoalAndSubmit must abort send when activation fails. Add behavioral tests for bridge failure, deferred source-tab ordering, and attachment-/workspace-ref-only initial Goals.

Verification: pnpm exec tsx src/__tests__/send-failed.test.ts; pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx; pnpm typecheck.

* fix(desktop): return SetGoalForTab errors across the Wails boundary

Problem: Frontend fail-closed Goal activation still continued structured Skill submits when the real bridge "succeeded", because Go SetGoalForTab returned void and remote/invalid-tab failures only warned.

Root cause: The Wails method swallowed remote RPC errors and missing/replaced tabs with a normal return, so the Promise always resolved. Cross-tab coverage also only exercised helper callback order, not SetGoalForTab/SubmitInvocationsToTab tab ids through the controller bridge.

Fix: Make SetGoal/SetGoalForTab/ClearGoal/ClearGoalForTab return error (remote RPC, missing tab, replaced tab). Route the first Goal turn through activateGoalAndSubmitOnTab so source tab ids are captured once. Add desktop Go error tests and a controller bridge harness that hangs SetGoalForTab(A), switches to B, and asserts Goal plus SubmitInvocationsToTab stay on A.

Verification: cd desktop && go test -count=1 -run 'TestSetGoalForTab|TestClearGoalForTab|TestMetaReportsGoalStatus|TestCollaborationModesPreserveToolApprovalMode|TestToolApprovalModesPreserveCollaborationMode' .; pnpm --dir desktop/frontend exec tsx src/__tests__/goal-activation-tab-routing.test.tsx; pnpm --dir desktop/frontend exec tsx src/__tests__/send-failed.test.ts; pnpm --dir desktop/frontend typecheck.

* fix(desktop): handle Goal bridge rejections

Problem: Interactive and background Goal calls discarded rejected Wails promises, allowing unhandledrejection to reach the global crash overlay.

Root cause: Composer callbacks, mode shortcuts, model commands, and controller resync paths invoked asynchronous Goal actions without a shared error boundary.

Fix: Route non-submit Goal actions through a toast-aware rejection handler, await model commands, and defer local Goal state updates until bridge success while preserving fail-closed structured submissions.

Verification: pnpm frontend test/typechecks, focused Goal routing and rejection tests, git diff --check, and cache-guard.sh.

* fix(desktop): fence initial Goal submission to its target

Problem:
An initial Goal could activate on Remote tab A, then submit its structured Skill to Local tab A after the user switched projections. The routing regression also was not part of the standard frontend test suite.

Root cause:
Goal activation and the first turn crossed Wails as separate calls identified only by tab ID. Wails schedules calls independently, while Local and Remote projections can reuse the same tab ID.

Fix:
Capture the source tab and workbench target token in React, validate both in one backend operation, and hold the workbench transition lock across Goal activation and first-turn submission. Fail closed on stale targets, keep zero-valued request sequences in target events, and cover frontend bridge plus Go backend behavior.

Verification:
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- cd desktop && go test ./...
- cd desktop && go test -race -count=1 -run 'TestSubmitInitialGoal|TestWorkbenchTargetEventKeepsZeroRequestSequence|TestSetGoalForTab|TestClearGoalForTab|TestSetActiveTabSwitchesRemoteProjectionOnlyForDifferentTab' .
- cd desktop && go vet ./...
- ./scripts/cache-guard.sh
2026-07-29 09:37:40 +08:00
SivanColaandipanta 170b23fbde feat(desktop): add integrated terminal sessions (#6994)
* feat(desktop): add integrated terminal sessions

Add a right-dock xterm terminal with multi-session controls, workspace-relative start directories, and file-tree entry points. Use Unix PTY and Windows ConPTY backends with bounded session counts, output buffering, stale-tab checks, read-only and remote guards, and application shutdown cleanup. Keep shell resolution renderer-safe by accepting only backend-approved shell IDs and reading custom shell paths from user-global config.

Refs #6513
Refs #6891

Verification: desktop Go race tests, Windows amd64 cross-compile, frontend terminal tests, full frontend test suite, typecheck, CSS checks, and production build.

Co-authored-by: ipanta <[email protected]>

* feat(desktop): scope terminal to each chat tab

Align the integrated terminal with the chat-scoped Codex workflow. Bind sessions to the active chat tab and workspace, close only that tab's PTYs on tab close, and render the terminal as a responsive bottom drawer.

Expose a bounded terminal output snapshot through an explicit user action that adds it to the composer. Do not inject terminal output into provider prompts or bypass the existing agent shell approval path.

Verification: desktop terminal race tests, Windows amd64 cross-compile, frontend typecheck, terminal and workspace-layout tests, CSS checks, and production build.

Co-authored-by: ipanta <[email protected]>

* fix(desktop): harden integrated terminal lifecycle

Problem:
- Terminal output was lost after switching sessions or reopening the drawer, and concurrent session creation could overwrite renderer state.
- Closing a chat tab could race an in-flight terminal start, while Windows coverage did not exercise the real ConPTY path.
- The terminal drawer duplicated controls and loaded xterm into the initial application bundle.

Root cause:
- Output was buffered only while no sink was attached, state updates reused stale workspace snapshots, and the backend did not remember closed tab IDs.
- Terminal-specific frontend tests were outside the standard suite, and the Windows executable fixture lacked an .exe suffix.

Fix:
- Keep bounded per-session output history, clear it only when a session is removed, and merge concurrent session updates atomically.
- Tombstone closed tabs before process cleanup, reject late starts, suppress stale output, and distinguish natural exits from removals.
- Add backend-approved shell selection, compact horizontal session tabs, lazy terminal loading, Windows ConPTY coverage, and CI wiring.

Verification:
- Desktop terminal race tests, full desktop tests, and go vet pass.
- Windows amd64 test-binary cross-compilation passes.
- Full frontend tests, TypeScript type checking, and production bundle checks pass.

* fix(desktop): close integrated terminal review gaps

Problem:

- Read-only transitions and workspace rebinding could leave existing shells alive or allow a slow shell start to register against stale tab state.
- First-open, retry, concurrent create, and single-session UI states could lose errors, duplicate requests, or hide the only close control.
- Terminal output added to chat could retain terminal control payloads or break its Markdown fence.

Root cause:

- Terminal lifecycle invalidation was not serialized with tab capability changes, and process exit did not wait for the output reader to drain.
- Renderer readiness used replace-style state updates without a retryable error model.
- Composer insertion treated raw terminal output as ordinary text.

Fix:

- Add per-tab generations, capability gates, process-output draining, process-group cleanup coverage, and macOS PTY CI.
- Make terminal readiness deduplicated and retryable, preserve concurrent sessions, expose operation errors, and keep bottom-drawer controls usable in all layouts.
- Strip ANSI, OSC, C1, and unsafe control sequences, bound inserted output, and choose a safe Markdown fence.

Verification:

- go test ./... and go vet ./... in desktop/
- go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' . in desktop/
- Windows amd64 and arm64 cross-builds for desktop/
- pnpm test, test:typecheck, test:remote, test:updater, test:performance, typecheck, test:terminal, and build
- go vet ./..., gofmt, YAML parse, and git diff --check

---------

Co-authored-by: ipanta <[email protected]>
2026-07-29 08:29:28 +08:00
32721fc0e1 feat(context): introduce Context Engine v2 / 引入上下文引擎 v2 (#7001)
* feat(memory): separate standing instructions from scoped facts

* fix(memory): preserve scoped memory compatibility

Problem:
Omitted-scope updates could move legacy global memories into the current project, and global preferences or feedback were unavailable on the first turn. Mixed-version clients could also route independently scoped facts through the wrong directory.

Root cause:
Save normalized an empty scope before consulting the active file, the stable prompt only contained the memory index, and previous releases treated metadata.type as the routing signal.

Fix:
Preserve the effective scope of same-name updates, snapshot global user and feedback bodies into deterministic lower-priority guidance, retain a previous-release routing type alongside authoritative fact_type metadata, and queue complete save/revocation notes for the current session.

Verification:
./scripts/cache-guard.sh
go vet ./...
env -u DEEPSEEK_API_KEY go test ./...
(cd desktop) go test ./...
(cd desktop/frontend) pnpm test
(cd desktop/frontend) pnpm build
gofmt -l .
git diff --check

* refactor(context): separate raw and provider user input

* refactor(instructions): resolve scoped context with provenance

* refactor(memory): add versioned fact storage

Context: Memory facts were addressed by mutable slugs, trusted a separately maintained index, and had no durable update history or conflict protection.

Change: Add stable IDs, monotonic revisions, atomic writes, derived indexes, deterministic legacy migration, scoped identity handling, revision restore, and optimistic update checks. Surface the metadata through tools and Desktop while preserving legacy frontmatter routing and Safe Mode behavior.

Compatibility: Existing files remain readable, gain deterministic runtime metadata before migration, and keep metadata.type/fact_type/scope values that previous releases understand. Added Desktop JSON fields are optional.

Verification: go test ./internal/memory; go test -race ./internal/memory; go test ./internal/boot -run Test.*Memory; go test ./... with provider credentials isolated; desktop go test ./...; frontend pnpm test and typecheck; go vet ./...; ./scripts/cache-guard.sh.

* feat(memory): recall relevant facts per user turn

Problem:
Saved facts were only visible through a stable index and required the model or user to invoke memory search manually.

Design:
Add conservative host-side BM25 recall for real user turns with project-over-global deduplication, freshness labels, explainable scoring, strict character limits, and local-home redaction. Keep the system prompt and tool schemas stable by appending recall only to the provider user-turn tail, and exclude synthetic turns and already-loaded global guidance.

Compatibility:
Preserve Store.List behavior and add ListAll for scope-aware consumers. No persisted memory or session fields change; display fallbacks strip the new transient suffix.

Verification:
go test -count=1 ./internal/memory ./internal/agent ./internal/control
go test -race -count=1 ./internal/memory
go test -race -count=1 ./internal/control with focused recall tests
go test -count=1 ./internal/tool/builtin/ ./internal/boot/
go vet ./...
./scripts/cache-guard.sh

* feat(memory): auto-save low-risk project facts

Problem:
Every remember call required a fresh approval, including bounded project facts that were new, non-sensitive, and low impact. This made durable context expensive enough that agents and users often skipped it.

Design:
Classify interactive remember requests against the scoped store. Automatically allow only explicit project/reference creates that are bounded, non-sensitive, and not semantic duplicates. Keep global, user/feedback, sensitive, and overwrite requests on the existing fresh-human path; keep headless and sub-agent mutation fail closed.

Safety:
Issue one-shot hashed grants and enforce create-only again inside the serialized Store write, preventing an approval-to-write race from becoming an overwrite. Clear abandoned grants at turn completion. Expose revision history and audited restore through the controller.

Compatibility:
The remember description and schema are unchanged. RequireCreate is a runtime-only SaveOptions field; persisted memory and session formats are unchanged.

Verification:
DEEPSEEK_API_KEY= go test -count=1 ./...
go test -count=1 ./... in desktop
go test -race -count=1 ./internal/memory ./internal/control with memory/approval tests
go vet ./...
./scripts/cache-guard.sh

* feat(memory): recover archived facts safely

Problem: Archived facts remained inspectable on disk but users could not recover them through Reasonix, and ad hoc restoration could overwrite active memory or lose revision provenance.

Fix: Add store-validated archive recovery with collision checks, exclusive active-file creation, monotonic revisions, revision snapshots, controller turn-tail refresh, and a Desktop restore action.

Compatibility: Existing active and archived Markdown files remain readable; recovery writes the established v2 frontmatter and hidden revision layout, while older binaries continue to read the restored active fact.

Verification: go test ./internal/memory ./internal/control; go test ./... in desktop; pnpm test; pnpm exec tsc --noEmit; ./scripts/cache-guard.sh.

* feat(context): expose memory recall diagnostics

Expose scoped fact freshness, project-over-global conflict decisions, and the last automatic recall trace through the desktop contract. Add revision history and restore APIs while preserving non-null collection responses and remote-workspace isolation.

This gives Context Center enough provenance to explain what was available, selected, suppressed, or restored without changing the stable prompt prefix.

* feat(context): add provenance center to desktop

Make scoped memories explainable in the desktop settings surface. Show stable identity, freshness, revision history, project-over-global resolution, automatic recall traces, instruction precedence, imports, and diagnostics.

The suggestions workflow remains compatible while the Context Center gains zero-impact read paths and explicit restore actions for user-controlled recovery.

* feat(memory): make suggestions zero-config

Open the suggestions surface with automatic local-history scanning by default, removing the hidden localStorage setup switch. Keep every candidate behind explicit confirmation.

Deduplicate against all scoped active facts and resolved instruction bodies so project and global memories cannot produce repeated guidance.

* feat(memory): add CLI diagnostics and recovery

Context:
Context Engine v2 exposed revision, recall, and instruction provenance in the backend and desktop, but the CLI only listed legacy merged facts and archives.

Changes:
- add shared /memory diagnostics for scoped facts, recall traces, instruction precedence, revisions, and archives
- add explicit revision restore and archive recovery commands with structured completion across CLI, desktop, and remote workspaces
- preserve same-name facts across scopes and keep narrow TUI output bounded

Verification:
- go test ./internal/control ./internal/cli ./internal/remote/workbench/runtime ./internal/memory -count=1
- go test -race ./internal/control ./internal/memory
- cd desktop && go test . -count=1
- ./scripts/cache-guard.sh

Cache-impact: management diagnostics only; provider-visible prompt and tool schemas are unchanged.

* docs(context): document instructions and memory v2

Context:
Context Engine v2 changed instruction precedence, automatic recall, safe memory writes, revision recovery, and desktop suggestions, while the user docs still described the legacy approval-only retrieval model.

Changes:
- rewrite the context and memory contract around standing instructions versus background facts
- document recall freshness, project-over-global resolution, zero-configuration writes and suggestions, revision/archive recovery, cache stability, privacy, and remote fail-closed behavior
- add a complete Chinese companion and update Guide, CLI, migration, spec, approval, and README navigation in both languages

Verification:
- validated relative Markdown links, code fences, and final newlines across 14 changed files
- scanned for stale approval/suggestion claims
- git diff --cached --check
- scanned the staged diff for secrets and machine-local paths

* fix(context): preserve safe headless memory writes

Allow controller-scoped low-risk project/reference creates to pass the non-interactive approval gate while keeping deny rules and all higher-risk memory operations fail-closed. Reject archive recovery through symlinked files or archive directories, with end-to-end regressions for both boundaries.

* fix(memory): reject non-directory migration roots

* fix(context): make instruction and memory references safe

Problem:
Provider-visible instruction paths exposed machine-local roots, memory results exposed store paths, and listed scoped references could not be used directly for read, update, revision, or archive operations.

Root cause:
Instruction rendering used absolute source paths and memory lookup treated display names as unqualified slugs. Symlink reads also crossed boundaries through a path-resolution gap, while scope-aware listing deduplicated same-name facts.

Fix:
Render stable workspace/user provenance, confine instruction and import reads to their owning roots, reject external symlinks, and support project/<name>.md and global/<name>.md references end to end. Preserve unqualified compatibility, stable-ID renames, revision snapshots, exact-scope archive behavior, low-risk create-only approval boundaries, and bilingual documentation.

Verification:
Focused instruction/memory tests and race tests; go test -count=1 ./...; go test -race ./...; go vet ./...; desktop go test/race/vet; pnpm test:all; pnpm build; go run ./cmd/remote-protocol-gen --check; ./scripts/cache-guard.sh (8/8 pass, long tool loop tail average 95%); staged privacy scan clean.

* docs(contributors): attribute adopted context work

Attribution:
Record the authors whose prior pull request mechanisms were adapted by the Context Engine v2 integration.

- cyq1017: feedback injection from #5710 and Markdown input normalization from #5603
- salarkhannn: Markdown input normalization from #5604

Scope:
This metadata-only commit adds repository-level co-author attribution without changing runtime behavior.

Verification:
Confirmed source PR ownership and both public GitHub noreply identities.

Co-authored-by: cyq <[email protected]>
Co-authored-by: Salar <[email protected]>

* test(desktop): allow slow Windows git metadata probe

Problem:
The Windows Desktop CI job failed when MetaForTab's asynchronous first git probe did not finish within the test's one-second deadline.

Root cause:
The production path intentionally launches the cold git branch lookup in the background so tab metadata never blocks. Windows runner process startup can exceed one second under normal CI load, making the test timing assumption platform-fragile.

Fix:
Keep the non-blocking metadata implementation unchanged and extend the cross-platform test observation window to five seconds, with a comment documenting the Windows process-startup variance.

Verification:
- GOCACHE=/tmp/reasonix-desktop-go-cache go test -count=1 -run 'TestMetaForTabIncludesWorkspaceContext|TestWorkspaceGitBranch' ./desktop
- git diff --check

---------

Co-authored-by: cyq <[email protected]>
Co-authored-by: Salar <[email protected]>
2026-07-29 02:27:29 +08:00
PaulDing98andSivanCola 241d4f5250 fix(cli): harden keyless default model fallback / 修复 CLI 无密钥默认模型回退 (#7002)
* fix(cli): fall through keyless default_model to next configured provider

The CLI built sessions on whatever cfg.DefaultModel said, even when that
provider had no API key in the current environment. Every command
(
easonix -p, 
easonix run, 
easonix review, 
easonix acp,
subagent runs) then hard-failed with 'missing env X_API_KEY' until the
user manually picked a different model in the bottom switcher, even
when another configured provider was sitting right there (#6996).

A companion fix to desktopNewSessionModel in #6999, which only covered
the desktop NewSession path. This change covers the shared boot.Build
entry point used by every CLI subcommand plus the six pre-Build call
sites that resolve the bootstrap / banner / effort / ACP model ref.

Semantics:
  - No explicit --model: use cfg.DefaultModel when configured, else
    fall through to the first provider that has a configured key. When
    nothing is configured the raw default is preserved so the existing
    'unknown model' / 'missing env' messages still surface.
  - Explicit --model (or session-level p.Model / f.model): strict
    resolve, fail loud on keyless or unknown. We must never silently
    reroute a model the user asked for by name.

Verification:
  - go test -count=1 ./internal/cli/... ./internal/boot/... ./internal/config/...
  - go vet ./...
  - gofmt -l internal/cli/ internal/boot/
  - manual: a config with keyless default_model + a configured second
    provider now boots onto the second provider (the prior build
    hard-failed on the keyless one).
  - manual: --model <keyless_ref> still errors; --model <configured_ref>
    still works.

Cache-impact: none - CLI/boot model selection only, no provider-visible
prompt, tool schema, memory prefix, or cache key touched.
Cache-guard: TestBuildKeylessDefaultFallsBackToConfiguredProvider +
TestBuildExplicitKeylessModelStillFails in internal/boot, plus the
8-case TestResolveModelForCLI table in internal/cli/default_model_test.
System-prompt-review: N/A.

* fix(cli): preserve explicit model in TUI state

Keep the controller's resolved model reference as the TUI source of truth so interactive keyless recovery and later controller rebuilds never reinterpret an explicit model as an implicit default. Add a regression covering an explicit keyless model with the interactive RequireKey=false path.\n\nCo-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-29 00:44:43 +08:00
SivanCola 111497a9b5 fix(agent): stop stale edit retry loops across Goal continuations / 阻止目标模式陈旧编辑重试死循环 (#7012)
* fix(agent): stop stale edit retry loops

Problem:
Goal mode could alternate successful file reads with repeated stale edit_file or multi_edit calls, bypassing the existing consecutive-failure guard and wasting automatic continuation turns.

Root cause:
The host treated any successful read as progress and had no goal-scoped, target-aware memory of failed write intents.

Fix:
Track semantic failed-write signatures across one user turn or stable Goal scope, invalidate them only for overlapping mutations, and re-check stale-anchor applicability through the existing side-effect-free preview before blocking a retry.

Verification:
- go test ./internal/agent -run 'TestRepeatGuard' -count=1
- go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- ./scripts/cache-guard.sh

* fix(agent): retain stale edit failures across file changes

Problem:
Repeated stale anchor failures could regain retry budget after another successful edit to the same file or after changing the target path spelling.

Root cause:
The Goal-scoped guard cleared every overlapping failure record, keyed signatures with raw paths, and treated dynamic nearest-line diagnostics as the error class.

Fix:
Preserve Preview-recheckable anchor failures across mutations, normalize anchor error classes and target paths, and cover real edit_file state changes plus path aliases.

Verification:
- go test ./internal/agent -run 'TestRepeatGuard' -count=1
- go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./...
- ./scripts/cache-guard.sh

* fix(agent): reset recoverable failures between goal runs

Problem:
Ordinary write failures were retained across Run calls in the same Goal scope, so a recovered permission or external-state issue could be blocked before retry execution.

Root cause:
The Goal-scoped repeat-failure map preserved every write-like failure even though only stale-anchor failures have a side-effect-free state recheck.

Fix:
Keep stale-anchor failures across Runs and discard ordinary write failures at the next Run boundary. Add a regression test proving the write executes again in each Run.

Verification:
go test ./internal/agent -run 'TestRepeatGuard' -count=1
go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
go test ./internal/control -run 'TestRecoveryPauseKeepsGoalRunningAndDeliveryScope|TestGoalReadinessFailureBlocksAndKeepsDeliveryScope' -count=1
go vet ./...
env -u DEEPSEEK_API_KEY go test ./... -count=1
./scripts/cache-guard.sh

* fix(agent): tailor repeated write failure guidance

Problem:
The repeat-failure guard used stale-anchor recovery instructions for every repeated write failure, including permission and filesystem errors.

Root cause:
The block message did not distinguish Preview-recheckable anchor failures from ordinary write failures even though the failure record already carried that classification.

Fix:
Keep anchor-specific guidance for stale edit failures and return generic write-condition guidance for ordinary failures. Extend the permission-denied regression test to reject stale-anchor wording.

Verification:
go test ./internal/agent -run 'TestRepeatGuard' -count=20
go test -race ./internal/agent -run 'TestRepeatGuard' -count=1
go test ./internal/control -run 'TestRecoveryPauseKeepsGoalRunningAndDeliveryScope|TestGoalReadinessFailureBlocksAndKeepsDeliveryScope' -count=10
go test ./internal/agent -count=1
GOCACHE=/private/tmp/reasonix-go-cache-vet2 go vet ./internal/agent
./scripts/cache-guard.sh
2026-07-28 16:42:15 +00:00
par73eandSivanCola 4ef8971afd fix(desktop): allow skill invocation anywhere in the composer (#6964)
* fix(desktop): allow skill invocation anywhere in composer

* Fix composer invocation interaction regressions

Problem:
Composer invocations could clip WebView2-hosted text, leave command suffixes when selected mid-token, lose pointer-accessible removal, and retain stale slash completion state across draft switches.

Root cause:
The caret anchor forced a one-pixel hidden box, slash replacement ranges stopped at the caret, the invocation badge remove callback was dropped, and draft restoration updated only the selection ref.

Fix:
Allow caret anchors to expand, replace complete slash tokens, restore accessible badge removal, synchronize restored plain selections, and cover each behavior with focused regressions.

Verification:
- rich-composer-selection: 99 passed
- composer-goal-toggle: 195 passed
- composer resized input CSS: 7 passed
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- pnpm build
- browser computed-style check
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Restore rich slash queries across session switches

Problem:
Rich composer drafts restored their text and invocation entities after a session switch but could leave slash completion closed. Deferred caret work from an earlier activation could also overwrite the restored selection after a rapid A-to-B-to-A switch.

Root cause:
Draft restoration synchronized only the plain textarea selection, and composer animation-frame callbacks were not scoped to a specific draft activation.

Fix:
Restore both selection models, recompute rich slash completion when invocation entities are present, and discard deferred selection or focus work once the draft activation changes. Cover deterministic stale-frame draining, A-to-B-to-A isolation, and second-skill selection.

Verification:
- composer goal toggle: 200 passed
- rich composer selection: 99 passed
- composer resized input CSS: 7 passed
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- pnpm build
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-29 00:26:28 +08:00
SivanCola 26b256b5d5 refactor(agent): simplify run/tool state and harden live tests / 重构 Agent 状态并隔离真实测试 (#7010)
* refactor(agent): add TaskTool Options constructor / 增加 TaskTool Options 构造入口

Problem:
NewTaskTool takes 18 positional parameters, which is hard to extend and
easy to mis-order at call sites.

Root cause:
Construction was only exposed as a long positional helper with no
structured options type.

Fix:
- Add TaskToolOptions and NewTaskToolWithOptions as the internal standard
  entry point (empty SysPrompt still maps to DefaultTaskSystemPrompt).
- Keep the 18-arg NewTaskTool as a compatibility wrapper for one full
  iteration cycle.
- Migrate boot wiring and the shared test helper to Options.
- Add equivalence tests for config, schema, empty prompt, resolver, and
  execute behavior.

Verification:
go test ./internal/agent -run 'TestNewTaskToolWithOptions|TestTaskTool' -count=1
go test ./internal/boot -count=1

* refactor(agent): split Run and executeOne state machines / 拆分 Run 与 executeOne 状态机

Problem:
Agent.Run and executeOne mixed lifecycle, turn policy, and tool policy in
two very large methods, raising cognitive load without changing behavior.

Root cause:
Turn-loop counters and tool-call gating lived inline instead of package-
private staged helpers with a single ownership model for locks/defers.

Fix:
- Keep Run as the lifecycle owner of workspace lease, evidence commit,
  delivery checkpoint, steer queue, and active-turn cleanup.
- Extract runLoopState plus beginRunTurn, runToolLoop, handleFinalResponse,
  and handleToolRound without changing stream/persist/batch/compact order.
- Split executeOne into parseToolCall, resolveToolPolicy (plan/proxy,
  delivery, recovery/permission), prepareToolExecution, and
  finishToolExecution behind toolCallPlan, preserving permission-before-
  lease and PreToolUse-before-execute semantics.

Verification:
go test ./internal/agent/... -count=1
go test -race ./internal/agent/... -count=1
REASONIX_RELEASE_CACHE_GUARD=1 go test ./internal/agent -run '^TestReleaseCacheHitGuard$' -count=1

* ci: report-only agent complexity monitor / 增加 agent 复杂度报告模式监控

Problem:
The repo already runs gofmt, vet, build, full tests, race, and
golangci-lint, but had no trend signal for remaining internal/agent
hotspots after structured extraction.

Root cause:
Main lint intentionally avoids funlen/cyclop hard fails that would
generate a flood of pre-existing noise.

Fix:
- Add a report-only funlen+cyclop config scoped to internal/agent
  production code with generous thresholds (not a hard 50-line rule).
- Run it beside the existing lint job with issues-exit-code=0, write a
  CI summary, and upload the report as an artifact.
- Ignore the generated local report file.

Verification:
bash scripts/agent-complexity-report.sh

* test(provider): isolate live DeepSeek cache probe / 隔离 DeepSeek 真实缓存探针

Problem:
The environment-gated DeepSeek cache probe ran during ordinary test suites whenever a stale API key was present, and its diagnostics still claimed tool-call reasoning replay should have zero prompt-token cost.

Root cause:
The probe had no explicit live build tag and retained commentary from the obsolete policy that stripped reasoning_content from DeepSeek tool-call turns.

Fix:
Gate the probe behind the live build tag, document the tagged invocation, describe required replay versus the empty-key recovery fallback, and fail when the required replay request is rejected.

Verification:
- go test ./...
- go test -race ./internal/provider/openai -count=1
- env -u DEEPSEEK_API_KEY go test -tags live ./internal/provider/openai -run TestRealDeepSeekCacheProbe -count=1 -v
- go vet ./internal/provider/openai
- ./scripts/cache-guard.sh
2026-07-28 23:16:58 +08:00
SivanCola 87dcb73114 fix(desktop): prevent freezes in large sessions / 修复大型会话卡顿 (#6993)
* fix(desktop): prevent freezes in large sessions

Problem:
Large session collections, rapid profile/model changes, and long streaming responses could trigger repeated filesystem scans, redundant controller rebuilds, and expensive transcript-wide rerenders. Remote Profile and Goal persistence could also diverge if the process stopped between durable writes.

Root cause:
Project-tree refreshes invalidated a global cache, profile axes crossed independent asynchronous calls, model changes queued every intermediate selection, and streaming Markdown reparsed the growing document on every delta. Remote session registry and Goal sidecar updates had no recovery journal.

Fix:
Add directory-scoped cache generations with TTL reconciliation, serialized atomic composer profiles, coalesced model switching, event-driven tab metadata refresh, an external live-stream store, batched stable Markdown rendering, lazy frontend chunks, and bundle/performance budgets. Preserve empty-history Goal state and make remote Profile plus Goal updates crash-recoverable with a durable transaction journal.

Verification:
- go test -race ./... in root and desktop modules
- focused cache, Profile, Goal, and model lifecycle race tests repeated 10 times
- pnpm test:all and production frontend build
- go vet ./... in root and desktop modules
- protocol generation drift and cache guard checks
- Windows amd64 and Linux amd64 cross-compilation checks

* fix(desktop): eliminate metadata and task rollback races

Problem:
Concurrent AutoResearch task creation could select the same directory ID across Store instances, and a failed durable Goal rollback could remove another transaction's task. Tab metadata refreshes could also apply a snapshot begun before a tab mutation.

Root cause:
Task IDs were chosen by a check-then-create sequence and deletion had no ownership proof. The bounded tab refresh coordinator treated a saturated in-flight request as current even after a backend mutation.

Fix:
Reserve task directories with atomic Mkdir and attach an opaque creation token required for rollback deletion. Add mutation generations and a latest-only trailing refresh to the tab metadata coordinator, then use it for lifecycle and explicit tab mutations.

Verification:
- go test ./internal/autoresearch
- go test -race ./internal/autoresearch
- go test -race ./internal/control ./internal/remote/workbench/runtime
- go test ./... (desktop module)
- pnpm --dir desktop/frontend test:all
- go vet ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime

* fix(remote): recover AutoResearch profile side effects

Problem:
Prepared Profile+Goal recovery restored the session registry and Goal sidecar but could leave a newly-created AutoResearch task running after a crash.

Root cause:
The task ownership token was generated inside task creation and was not durable in the parent transaction before the task directory appeared.

Fix:
Persist a transaction-owned create token in the prepared journal, bind transaction task IDs to a hash of that token, and remove the uniquely-owned task during prepared rollback and restart recovery. Preserve committed tasks and cover incomplete directory reservations.

Verification:
- go test ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime
- go test -race ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime
- go vet ./internal/autoresearch ./internal/control ./internal/remote/workbench/runtime

* ci: retry final baseline checks
2026-07-28 23:14:02 +08:00
SivanCola fe2ad317fa fix(desktop): resolve eligible defaults for new sessions / 为桌面新会话解析可用默认模型 (#6999)
Fixes #6996
2026-07-28 17:48:04 +08:00
EMEEEEMMMM 1e722fa84a fix: copy-paste restores original LaTeX source instead of Unicode glyphs
Desktop (Wails):
- MarkdownRenderer annotates .katex/.katex-display/.katex-html with
  data-latex-source attribute from rehype-katex MathML annotation
- messageSelectionCopy handler reads data-latex-source and replaces
  KaTeX rendered text with the original $...$ / ... LaTeX markup
- Auto-locks to the full formula when any part is selected
- Collects all display blocks in the range for multi-row formulas

CLI (TUI):
- mathNode retains both value (Unicode display) and source (raw LaTeX)
- mdRenderer gains rawMath mode and RenderRaw() method
- renderAssistantMarkdownRaw builds a parallel transcript with
  raw LaTeX preserving $...$ delimiters
- selectedText() uses rawWrappedLines when available to restore
  original LaTeX on clipboard copy

Fixes: Ctrl+C copy of LaTeX math expressions in both desktop and CLI
2026-07-28 16:43:21 +08:00
0efda3bb9f fix(desktop): clear stale session data when reusing blank tab / 修复新建会话时展示旧工具调用记录的问题 (#6909)
* fix(desktop): clear stale session data when reusing blank tab / 修复新建会话时展示旧工具调用记录的问题

When creating a new session tab via handleNewTab → openBlankSession → ensureBlankTab, the backend may reuse an existing blank tab that still carries stale session data (e.g. tool calls from a previous session). The frontend then loads this stale data via HistoryPageForTab, rendering 'N 个工具' tool-fold cards that should not appear in a fresh session.

The fix calls app.NewSessionForTab(meta.id) after EnsureBlankTab returns, mirroring what newSession() already does, to guarantee the backend session is clean before loadSessionDataForTab runs. A silent catch with addBreadcrumb preserves diagnostic signal when the backend refuses.

Cache-impact: none — only desktop/frontend TypeScript, no cache-sensitive internal paths.

* Fix reused blank tab hydration

Problem:
Reusing an existing blank tab could leave stale tool cards visible and allow an earlier history request to repopulate them.

Root cause:
The frontend kept the existing tab state and used a non-reset hydration. The added NewSessionForTab call did not invalidate local requests and could no-op for an already blank backend session.

Fix:
Remove the redundant backend session rotation, invalidate checkpoint work, and force a reset hydration bound to the TabMeta session path.

Verification:
- pnpm exec tsx src/__tests__/new-session-load-race.test.tsx
- pnpm exec tsc --noEmit
- cd desktop && go test ./...
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Add reused blank tab hydration regression

Contract:
A reused blank tab starts a fresh history load, clears orphaned tool cards immediately, and rejects late history from the previous session.

Coverage:
Add an integration-style useController test that holds the startup history request, reuses the same tab id with a new session path, and releases the stale tool history after the fresh empty hydration completes.

Verification:
- pnpm exec tsx src/__tests__/new-session-load-race.test.tsx
- pnpm exec tsc --noEmit
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Fix stale desktop navigation completions

Problem:
A tab click made while blank, topic, or session navigation was in flight could be overwritten by the older completion. The frontend could show an abandoned tab or disagree with the backend active tab.

Root cause:
Navigation and tab-switch queues tracked last-click ownership independently, and controller navigation helpers advanced their epoch only when execution began. A queued click therefore did not invalidate an already-running operation, while late metadata and hydration work could still apply.

Fix:
Capture one shared navigation intent at enqueue time, propagate it through composite controller operations, and reject stale UI, hydration, and metadata updates. When a stale backend navigation already activated its result, reassert the currently visible tab so frontend and backend focus converge.

Verification:
- new-session-load-race: 28 passed
- tab-switch-hydration: 75 passed
- activate-topic-stale: 8 passed
- open-topic-coalescing: 15 passed
- app-chrome-tabs: 83 passed
- pnpm exec tsc --noEmit
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Fix stale navigation reassertion races

Problem:
A stale tab switch or fork repair could finish after a third navigation and leave the backend active tab behind the visible frontend tab.

Root cause:
Both stale-completion paths captured the visible tab once and issued a single SetActiveTab call without rechecking ownership after the RPC returned.

Fix:
Route stale switch and fork completions through the shared reassert loop so each await is followed by a fresh visible-tab check.

Verification:
- pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx
- focused desktop frontend navigation matrix
- pnpm exec tsc --noEmit
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Add stale navigation repair regressions

Contract:
A stale tab-switch or fork repair must keep following navigation ownership when a third navigation completes while SetActiveTab is still pending.

Coverage:
Use deferred backend gates to force both three-way interleavings and assert that the latest tab remains visible and backend-active.

Verification:
- pnpm exec tsx src/__tests__/tab-switch-hydration.test.tsx (79 passed)
- focused desktop frontend navigation matrix (409 passed)
- pnpm exec tsc --noEmit
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: xiaoshuai1024 <[email protected]>
Co-authored-by: Sivan <[email protected]>
2026-07-28 06:19:02 +00:00
HaoyueQin 1dea6f696c fix(desktop): persist context usage and per-turn breakdown across session rebinds / 跨会话重建持久化上下文占用和单轮明细
Session rebind (project-tree switch) rebuilds the controller with a fresh
executor whose LastUsage() returns nil and ContextSnapshot() reports used=0.
The telemetry file (.telemetry.json) already had cumulative totals but was
missing three categories of data needed after a rebind:

1. Context fill (used): now persisted as LastUsedTokens in sessionUsageStats
   and used as a fallback in ContextUsageForTab/ContextPanel when the executor
   reports used=0.

2. Per-turn token breakdown (prompt/completion/reasoning/cache hit/miss): now
   persisted as Last* fields and restored in ContextPanel when LastUsage() is
   nil, keeping the donut chart and type breakdown accurate.

3. Right-panel refresh timing: a new contextPanelSeq counter is bumped after
   hydration ancillary data lands, merged into ContextPanel's refreshKey so
   RequestCount/ElapsedMs/SessionCost re-fetch after a rebind instead of
   showing stale data.

Frontend also drops the stillVisible guard on ancillary context/effort/jobs
dispatch (tab-scoped dispatchTo is safe for non-visible tabs) and adds an
activeTabId-driven fallback effect for StatusBar.

Fixes: #5335, #5766
2026-07-28 13:59:18 +08:00
PaulDing98andSivanCola 171343542e fix(jobs): preserve trusted session path semantics / 保持可信 session 路径语义 (#6937)
* fix(jobs): reject traversal in SetActiveSessionPath sessionPath

Follow-up to the issue #6932 thread (the StartForSession half is fixed in
#6936). SetActiveSessionPath trimmed sessionPath but did not reject `..`
components. Combined with store.SessionJobsDir(sessionPath) (which builds
<id>.jobs from the transcript path), a malicious or malformed sessionPath
like "[tmp][..][sibling][passwd.jsonl]" would resolve to a sibling
sidecar directory and then flow into:

  * m.artifactDirs[parentSession] = newDir
  * migrateArtifactDirForSession (file copies to the caller-picked dir)
  * loadSessionArtifacts (reads meta files from the caller-picked dir)

This change:

  * Adds validateSessionPath, which rejects empty input, NUL and control
    characters, and any path component equal to "..". Unlike
    validatePathSegment, separators are allowed because sessionPath is an
    absolute or workspace-relative path. Names like "..hidden" and "..."
    remain accepted as legitimate filenames; only ".." as a standalone
    component is rejected.

  * Calls validateSessionPath at SetActiveSessionPath entry, after trim
    and before any lock or filesystem side effect. A failure emits a
    Warning notice through the manager's sink (so the caller can observe
    the rejection in its event stream) and returns immediately without
    touching m.active, m.artifactDirs, or the migration/load paths.

  * Adds jobs_sessionpath_test.go with a 15-case table for
    validateSessionPath, plus three integration tests:
      - TestSetActiveSessionPath_RejectsTraversalPayload: a payload
        containing ".." does not create a sidecar directory next to a
        canary file, leaves m.artifactDirs empty, and emits the expected
        warning.
      - TestSetActiveSessionPath_RejectsLeadingDotDot: a leading ".."
        component is rejected the same way.
      - TestSetActiveSessionPath_AcceptsValidInput: a typical
        t.TempDir()/abc.jsonl input still flows through to
        store.SessionJobsDir, caches correctly, and a follow-up
        StartForSession writes the artifact next to the transcript.

This PR is independent of #6936: validateSessionPath does not call
validatePathSegment, so the two can be reviewed and merged in either
order. Both fixes share the underlying issue #6932 threat model.

* fix(jobs): validate session paths across platforms

Problem: SetActiveSessionPath traversal validation missed Windows-style backslash components on Unix and changed the legacy empty-path behavior.

Root cause: filepath.ToSlash only rewrites the current platform separator, and validation ran before the active-only empty-path branch.

Fix: Normalize both separator styles explicitly, keep empty paths as active-only calls, document the trusted-path boundary, and add regression coverage.

Verification: go vet ./internal/jobs/...; go test -count=1 ./internal/jobs/...; go test -race -count=1 ./internal/jobs/...; go test -count=1 ./...

Co-authored-by: SivanCola <[email protected]>

* fix(jobs): preserve trusted session path semantics

Problem: Trusted transcript paths containing valid .. or host-specific separator characters were rejected, and rejection left active-session routing and persistent bindings stale.

Root cause: The validator conflated lexical path syntax with trusted-root containment and returned before updating independent session-routing state.

Fix: Limit trusted-path validation to control characters, preserve the caller path spelling, update active routing on rejection, clear stale bindings, and add regression coverage for relative paths and invalid rebinding.

Verification: go vet ./internal/jobs/...; go test -count=1 ./internal/jobs/...; go test -race -count=1 ./internal/jobs/...

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-28 13:45:47 +08:00
2fefeef06f feat: add regional CNY/USD pricing and English localization / 增加区域人民币/美元价格与英文国际化 (#6945)
* feat(desktop): add regional pricing and English localization

Problem: Desktop cost estimates assumed a single CNY price table, while automatic session titles and several desktop options remained Chinese-only for English users.

Root cause: Currency selection was coupled to persisted language settings, browser locale never reached controller rebuilds, cost telemetry lacked consistent regional repricing, and fallback copy was not localized at the API boundary.

Fix: Add official CNY/USD regional pricing with auto locale detection and an explicit desktop override, preserve structured currency telemetry and custom prices, localize session titles and missing desktop options, and make generated titles follow the user's language.

Verification: Passed config/boot/serve and full desktop Go suites, frontend typecheck and production build, focused frontend behavior tests, the cache guard, and git diff checks.

* fix: preserve regional pricing and manual topic titles

Problem:
Auto regional pricing could overwrite persisted official price tables, and manually entered default-like topic titles were localized.

Root cause:
Pricing normalization did not retain TOML provenance, while title localization inferred automation from title text alone.

Fix:
Carry regional pricing provenance through config loading, normalization, backfill, and edit replay; only explicit currency choices override persisted tables. Propagate topic title source through desktop runtimes and localize only auto-generated default titles.

Verification:
go test ./internal/config ./internal/boot ./internal/serve -count=1
cd desktop && go test ./... -count=1

* chore: credit regional localization contributors

Context: PR #6945 consolidates regional pricing preservation and English session-title work from three related contributor PRs.

Attribution: Record the source authors with their public GitHub noreply identities so the consolidated repository history preserves their contributions.

Verification: Attribution-only commit; product files are unchanged.

Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: erphenheimer <[email protected]>
Co-authored-by: delight-f <[email protected]>

* feat(cli): add regional pricing controls

Context: PR #6945 already provides regional DeepSeek pricing in desktop, but CLI startup, in-session settings, and JSON result output did not expose the same currency contract.

Changes: Resolve Auto pricing from the active CLI locale, add global currency config and slash commands, refresh the current controller after language or currency changes, and emit currency-aware total_cost while retaining total_cost_usd as a compatibility alias. Reject mixed-currency totals and prevent project config from overriding user regional preferences.

Verification: go test ./internal/cli ./internal/config ./internal/boot ./internal/control ./internal/i18n -count=1; go test ./... -count=1 with DEEPSEEK_API_KEY unset.

* docs(cli): document regional pricing controls

Documentation:
- Add English and Chinese examples for global currency selection and locale-based Auto.
- Explain structured cost/currency output and the total_cost_usd compatibility alias.
- List the /currency session command and runtime refresh behavior.

Verification:
- go test ./internal/cli -run "Test(ConfigCurrency|RunOutput)" -count=1
- git diff --check

---------

Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: erphenheimer <[email protected]>
Co-authored-by: delight-f <[email protected]>
2026-07-28 13:45:15 +08:00
Vikky 104b4db42c fix(remote): encode empty effort levels as an array 2026-07-28 13:12:40 +08:00
Yan Li 7ee229e0d8 fix(agent): repair orphaned paste labels on session load
When a session is resumed or its history replayed, orphaned
[Pasted text #N] labels that were never expanded (because
pastedBlocks was lost during a prior reload) are stored raw in
the transcript.  The model receives an opaque token it cannot
interpret.

repairOrphanedPasteLabels runs during loadSessionUnlocked:
  1. Scans the same transcript for prior expansions (--- Begin/End ---)
  2. Inlines recovered content into orphaned labels
  3. Replaces unrecoverable labels with [pasted content unavailable]

This cleans historical pollution at load time, complementing the
TUI-layer fix (chat_tui_paste.go) that prevents new orphaned
labels on submit.

Ref: #6948
2026-07-28 09:10:35 +08:00
SivanCola b907a6b097 Add privacy-preserving CLI telemetry and crash reports / 添加隐私友好的 CLI 遥测与崩溃报告 (#6984)
* feat(cli): add privacy-preserving telemetry and crash reports

Summary:
- add bounded CLI usage metrics with explicit policy controls and silent retry behavior
- capture sanitized panic reports locally and require user confirmation before upload
- separate Desktop and CLI aggregates in the crash-report worker and stats dashboard
- document telemetry fields, privacy guarantees, report commands, and D1 migration requirements

Compatibility:
- default missing client surfaces to Desktop for existing clients and payloads
- migrate existing aggregate rows to the Desktop surface before deploying the new worker

Verification:
- go test ./internal/telemetry ./internal/crashreport ./internal/config ./internal/cli ./cmd/reasonix
- env -u DEEPSEEK_API_KEY go test ./...
- pnpm test (workers/crash-report, 49 tests)
- pnpm run typecheck (workers/crash-report)
- ./scripts/cache-guard.sh

* fix(stats): decouple CLI schema migration from Worker deploy

Problem:
The initial surface migration rebuilt the existing Desktop telemetry tables and changed their primary keys. Running it before the new Worker broke the released Worker's ON CONFLICT clauses, while deploying the Worker first queried columns that did not exist yet.

Root cause:
Desktop and CLI isolation was implemented by changing a shared persisted schema, which made the old and new Worker contracts mutually exclusive during rollout.

Fix:
Keep the released Desktop tables and primary keys unchanged, route CLI telemetry to additive cli_* tables, and let the new Worker initialize those tables with idempotent CREATE IF NOT EXISTS statements. Keep the migration additive and identical to the runtime bootstrap so migration, deployment, and rollback order no longer affect Desktop ingestion.

Verification:
- pnpm test (workers/crash-report, 54 tests)
- pnpm run typecheck (workers/crash-report)
- git diff --check

* fix(test): make crash report mode check portable

Problem:
The Windows CI job failed because os.FileMode reports synthesized POSIX bits on Windows rather than the 0600 mode requested by AtomicWriteFile.

Root cause:
The test treated Unix permission bits as a cross-platform ACL signal even though Windows enforces file access through inherited ACLs.

Fix:
Keep the 0600 assertion on Unix-like systems and skip only that non-portable mode-bit comparison on Windows while retaining all report creation, sanitization, and bounded-retention checks.

Verification:
- go test ./internal/crashreport
- GOOS=windows GOARCH=amd64 go test -c ./internal/crashreport

* fix(cli): preserve actionable crash report groups

Problem:
CLI panic reports used runtime/debug.Stack as their top frame, so unrelated panics collapsed into one fingerprint. Queue pruning also deleted reports written in an unknown future format.

Root cause:
Top-frame extraction selected the first source location without filtering capture/runtime frames, and pruning treated every JSON file as a current report.

Fix:
Select the first application frame with its sanitized function name, retain a safe fallback, and prune only recognized current-schema report files. Reject newer schemas from old-client listing so their fields remain untouched.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/crashreport ./cmd/reasonix
- go vet ./...
- pnpm test (workers/crash-report, 54 tests)
- pnpm run typecheck (workers/crash-report)
- Windows cross-compile of internal/crashreport

* fix(cli): request consent before telemetry upload

Problem:
A fresh release-build interactive CLI treated a missing telemetry setting as auto and could start anonymous uploads without first informing the user.

Root cause:
The runtime default and persisted decision state were conflated, and telemetry startup had no consent owner before the reporter launched its background flush.

Fix:
Preserve a missing cli_metrics field as an undecided state, show a localized one-time [Y/n] disclosure before the first eligible upload, persist auto or off before continuing, clean pending counters on rejection, and fail closed when saving fails. Keep CI, Safe Mode, development, environment opt-outs, and non-interactive sessions silent and disabled until a decision exists.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/telemetry ./internal/cli
- go vet ./...
- pnpm test (workers/crash-report, 54 tests)
- pnpm run typecheck (workers/crash-report)
- ./scripts/cache-guard.sh
- git diff --check
2026-07-27 23:00:53 +08:00
PaulDing98andSivanCola dc77f2a8c8 fix(jobs): reject artifact path traversal / 拒绝 artifact 路径穿越 (#6936)
* fix(jobs): reject path traversal in artifact parentSession and kind

Issue: #6932

StartForSession trimmed parentSession but did not reject path separators
or '..' components. Combined with filepath.Join(tempRoot, parentSession, id)
and the subsequent os.MkdirAll + os.OpenFile, a caller controlling the
session id or kind could write artifact files outside the manager's temp
root, subject to the running process's filesystem permissions.

This change:

  * Validates parentSession and kind at StartForSession entry. A validation
    failure short-circuits before any goroutine or artifact is created; the
    job is registered as Failed with the validation error in artifactErr so
    list / Wait calls observe it.

  * Adds a defense-in-depth containment check in openArtifactLocked: the
    resolved dir must remain inside m.tempRoot. This catches any future
    path that reaches openArtifactLocked through a different route (e.g.
    SetActiveSessionPath with a malicious sessionPath).

  * Adds unit tests in jobs_security_test.go covering:
      - validatePathSegment table (valid + every rejected character class)
      - StartForSession with traversal parentSession (no file written)
      - StartForSession with traversal kind (no file written)
      - StartForSession with normal input (regression guard)
      - openArtifactLocked with an out-of-root dir in artifactDirs

* docs(jobs): clarify artifact containment boundary

Problem: The validator and regression-test comments implied every accepted artifact path remains under the manager temp root.

Root cause: The explanation overlooked trusted persistent sidecar directories bound by SetActiveSessionPath, which intentionally live next to transcripts.

Fix: Clarify that path-segment validation confines only the temp-root fallback while persistent directories come from trusted store paths.

Verification: go test -count=1 ./internal/jobs/...; go test -count=1 ./...

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-27 22:22:53 +08:00
SivanCola e57297959b Harden dynamic Bash authorization without disrupting Auto / 加固动态 Bash 授权并保持 Auto 低干预 (#6985)
* fix(permission): harden dynamic Bash authorization

Problem:
Broad Bash allow rules could silently authorize nested command and process substitutions, while treating every dynamic shell form as human-required would make Auto unnecessarily disruptive.

Root cause:
Bash rule matching did not distinguish reusable static commands, exact-only dynamic syntax, and nested or indirect execution before applying scoped allows and controller bypasses.

Fix:
Classify Bash ASTs into reusable, exact-only, and human-required tiers. Restrict all dynamic approvals to exact Bash=<literal> grants, require a human for nested or indirect execution outside YOLO, preserve Auto fallback for expansions, assignments, redirects, heredocs, and globs, and apply the same contract across interactive, headless, persistence, CLI, and ACP paths.

Verification:
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- gofmt -l .
- git diff --check

Fixes #6963

* fix(control): remove unused approval wrapper

Problem:
The CI lint job rejected PR #6985 because requestApprovalDecision was left unused after approval paths moved to the option-aware helper.

Root cause:
The dynamic Bash approval refactor replaced the wrapper call sites but did not remove the obsolete wrapper itself.

Fix:
Remove the unused wrapper without changing approval behavior.

Verification:
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- gofmt -l .
- git diff --check

* fix(permission): preserve dynamic compound rule precedence

Problem:
Dynamic compound Bash commands could reach fallback before per-segment Deny or Ask rules were evaluated. Raw rule matching also lost the established :* prefix semantics, and brace expansion or extglob arguments could be treated as reusable static commands.

Root cause:
The approval class short-circuited compound decomposition, the raw matcher interpreted Bash prefix markers as ordinary glob text, and the AST feature pass did not normalize brace expansion before testing whether words were static.

Fix:
Run compound segments through the same complete policy recursively, preserve Bash prefix semantics with AST-aware raw matching, prioritize wildcard tokens in glob matching, and classify brace/extglob or other non-static arguments as exact-only expansions using mvdan syntax nodes.

Verification:
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- gofmt -l .
- git diff --check

* fix(permission): detect indirect builtin execution

Problem:
Bash builtin eval and builtin source could inherit broad Bash authorization even though their direct forms require human approval.

Root cause:
The indirect-execution classifier recursively handled command, exec, env, and other wrappers, but omitted Bash's builtin wrapper.

Fix:
Treat builtin as a recursive command wrapper and add positive and negative regression cases so eval/source require a human while ordinary builtins stay reusable.

Verification:
- go test ./internal/shellparse ./internal/permission ./internal/control ./internal/boot ./internal/cli ./internal/acp
- go test -race ./internal/control
- go vet ./...
- gofmt -l .
- git diff --check
2026-07-27 22:09:03 +08:00
e406e719f6 Preserve MCP config ownership on save / 保存时保持 MCP 配置归属 (#6913)
* fix(config): prevent .mcp.json plugins from leaking into reasonix.toml / 防止 .mcp.json 插件泄漏到 reasonix.toml

RenderTOMLProjectDelta (used by saveProjectIncremental) now filters out
plugins sourced from .mcp.json (MCPSourceProjectMCPJSON). Previously,
saving the config after Load() merged .mcp.json servers into c.Plugins,
the TOML renderer would write them into reasonix.toml. On next load,
their Source provenance changed from project_mcp_json to project_config,
invalidating launch grants and activation receipts keyed by ConfigSource.

Fixes #6702.

* fix(config): preserve MCP plugin ownership on save

Problem:
Scoped TOML saves could copy merged MCP entries into the wrong config file and could leave a removed project plugin behind while foreign-source entries remained in memory.

Root cause:
The project delta filtered only .mcp.json entries, while full rendering and plugin-section removal still used the unfiltered runtime plugin list.

Fix:
Centralize destination-aware plugin filtering for full and incremental renders, retain unknown provenance for directly constructed configs, and mark explicit upgrade imports as user-owned before saving.

Verification:
- go test ./internal/config
- go vet ./internal/config
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: jiangrenhang <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-27 22:06:54 +08:00
mchenziyiandSivanCola 76f5414e6b Preserve machine session directory scope and add project root / 保留机器接口 Session 目录并支持项目根目录 (#6970)
* fix(machine): resolve project session scope for --dir

* test(machine): pin project session directory mapping

* fix(machine): preserve explicit session directory scope

Problem:
The PR reinterpreted the documented --dir session-store argument as a project root, which made existing machine Session, Task, and Recovery callers return empty or not-found results.

Root cause:
A single flag was used for two distinct path scopes instead of preserving the existing contract and adding an explicit project selector.

Fix:
Keep --dir as the session storage directory, add --project-root for project-scoped lookup, share directory resolution across Session and Task commands, reject conflicting selectors, and document both modes in English and Chinese.

Verification:
- go test ./internal/cli -count=1
- go test -race ./internal/cli with focused machine tests
- go vet ./internal/cli
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-27 22:02:21 +08:00
SivanCola 84c6484edb Fix Windows plugin Bash hook runtime detection / 修复 Windows 插件 Bash Hook 运行时探测 (#6980)
* fix: honor configured Bash for Windows plugin hooks

Problem:
Windows plugin hooks that explicitly request Bash can fail after upgrade even when a usable non-standard Git Bash path is configured. Expanded plugin paths can also remain in a form that Git Bash cannot consume.

Root cause:
Hook execution and diagnostics rediscovered Bash independently instead of receiving the effective configured shell path, while Windows plugin-root expansion did not normalize shell-form commands for Bash.

Fix:
Propagate the configured Bash runtime through hook execution and diagnostics, normalize expanded Windows plugin paths for explicit Bash hooks, and report missing runtime dependencies in CLI and Desktop doctor surfaces. Add Superpowers and custom-path regression coverage plus bilingual documentation updates.

Verification:
go test ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
go test -race ./internal/hook ./internal/capdiag
env -u DEEPSEEK_API_KEY go test ./...
(cd desktop && go test ./...)
go vet ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
(cd desktop && go vet ./...)
Windows amd64 cross-compilation for affected root and Desktop packages
git diff --check

Cache-impact: none

* fix: remove obsolete Windows hook wrappers

Problem:
The CI unused-code linter rejects two compatibility wrappers that became unreachable after Hook execution started injecting the effective Bash runtime.

Root cause:
The runtime-aware call sites moved to resolver-injected helpers, but the old zero-argument resolver wrappers remained in the Windows compatibility module.

Fix:
Remove the two obsolete wrappers and keep the shared runtime-aware parsing helpers and cached Bash discovery path.

Verification:
go test ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
go vet ./internal/hook ./internal/capdiag ./internal/boot ./internal/cli
gofmt -l internal/hook/windows_compat.go
git diff --check
bash scripts/check-cache-impact.sh with the PR metadata

Cache-impact: none

* test: normalize Superpowers Bash hook expectation

Problem:
The Windows CI test expected a host-native backslash plugin root even though explicit Bash hooks intentionally receive a forward-slash path.

Root cause:
The Superpowers 6.2.0 regression assertion joined the temporary plugin root without applying the same platform-independent slash contract that the Hook loader guarantees for Bash.

Fix:
Normalize the expected plugin root with filepath.ToSlash so the test verifies the intended Git Bash command on Windows and remains stable on Unix hosts.

Verification:
go test ./internal/hook
Windows amd64 test-binary compilation for internal/hook
gofmt -l internal/hook/hook_test.go
git diff --check

Cache-impact: none
2026-07-27 20:29:08 +08:00
wqshan-cn 89feda565e Merge upstream main-v2 and resolve rich-link conflicts 2026-07-27 20:01:28 +08:00
SivanColaandgithub-actions[bot] 9968f1e1e2 Update v1.18.0 bilingual release notes / 更新 v1.18.0 中英发布说明 (#6981)
* docs(release): prepare v1.18.0 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): refine v1.18.0 notes

Summary:
Clarify Kimi K3 support across the official Kimi APIs and OpenCode Go, retain the distinct reasoning scales, and record every affected product surface. Keep the Desktop and native CLI channel copy aligned with the merged release contract.

Verification:
Validate the catalog, run the release-notes test suite, render both languages, and check the diff for whitespace errors.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-27 19:40:30 +08:00
SivanCola acc4c54009 Add Kimi K3 support for OpenCode Go and official APIs / 为 OpenCode Go 与官方 API 适配 Kimi K3 (#6921)
* Add native Kimi K3 support for OpenCode Go

Problem:
Reasonix did not expose Kimi K3 in the OpenCode Go subscription preset even though the upstream catalog now serves it.

Root cause:
The curated OpenCode Go model catalog and capability metadata predated Kimi K3, while the generic OpenAI-compatible provider clamped max reasoning effort to high.

Fix:
Add Kimi K3 with vision, 1M context, and max-only reasoning metadata; preserve explicitly supported max effort; safely migrate untouched legacy presets; and synchronize desktop previews and documentation.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend exec tsx src/__tests__/provider-model-refresh.test.ts
- ./scripts/cache-guard.sh

* Fix Kimi K3 compatibility paths

Problem:
Kimi K3 max-only effort metadata was omitted by reasonix-guard assist, and legacy normalization replaced existing custom Kimi K3 overrides.

Root cause:
The recovery command duplicated provider configuration assembly, while the migration assigned the Kimi K3 override unconditionally.

Fix:
Forward resolved effort and capability metadata through the recovery path, merge only missing Kimi K3 defaults, preserve case-insensitive custom override keys, and add regression coverage.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- ./scripts/cache-guard.sh
- git diff --check

* Support Kimi K3 high and max effort

Problem: Kimi K3 was advertised as max-only even though the service accepts both high and max, so /effort high was rejected and capability displays were incomplete.

Root cause: The preset, legacy catalog migration, desktop mock, documentation, and regression fixtures all encoded the old max-only capability.

Fix: Advertise high and max while keeping max as the default, and update every compatibility path and focused regression test. Existing custom Kimi K3 overrides remain unchanged.

Verification: env -u DEEPSEEK_API_KEY go test ./...; (cd desktop && env -u DEEPSEEK_API_KEY go test ./...); pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend exec tsx src/__tests__/provider-model-refresh.test.ts; ./scripts/cache-guard.sh; git diff --check.

* Adapt official Kimi APIs for K3

Problem: The PR exposed Kimi K3 only through OpenCode Go, leaving the official Kimi CN and Global presets without the model or its documented request contract.

Root cause: The direct-API catalogs predated K3, while the generic OpenAI-compatible wire shape drops assistant reasoning, forwards configurable temperature, and uses max_tokens.

Fix: Add K3 capability overrides and conservative legacy catalog migration for both official endpoints; preserve complete assistant messages; omit fixed sampling fields; map output limits to max_completion_tokens; and keep relay behavior isolated.

Verification: env -u DEEPSEEK_API_KEY go test ./...; cd desktop && env -u DEEPSEEK_API_KEY go test ./...; pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend exec tsx src/__tests__/provider-model-refresh.test.ts; ./scripts/cache-guard.sh; git diff --check.

* Preserve K3 reasoning through remote broker

Problem:
Remote Workbench lost the Kimi K3 reasoning round-trip policy when the Desktop provider was represented by the Host broker proxy. A configured PostLLM hook could therefore replace the reasoning persisted for the next official K3 request.

Root cause:
The new ReasoningRoundTripPolicy was implemented by the OpenAI-compatible client and consumed by the Agent, but the broker descriptor and hostProvider only forwarded the older tool-call reasoning capabilities.

Fix:
Carry an optional reasoningRoundTrip capability through provider descriptors and the Remote wire schema, expose it from hostProvider, and add an end-to-end broker regression that proves display-transformed reasoning still replays the original provider text.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/remote/broker
- go vet ./...
- go test ./... (desktop module)
- pnpm typecheck (desktop/frontend)
- go run ./cmd/remote-protocol-gen --check
- ./scripts/cache-guard.sh

* Preserve custom vision settings during K3 migration

Problem: Kimi K3 migration appended vision support to every matching provider, including configurations that explicitly disabled or customized vision models.

Root cause: provider-level vision_models distinguishes an omitted nil list from an explicit empty or user-curated list, but the migration ignored that distinction.

Fix: add Kimi K3 vision support only for omitted lists or exact legacy preset lists, and preserve explicit empty and custom lists for direct Kimi and OpenCode Go migrations. Add regression coverage for both paths.

Verification: go test ./...; ./scripts/cache-guard.sh; go run ./cmd/remote-protocol-gen --check; git diff --check
2026-07-27 19:04:11 +08:00
SivanCola f62790387a docs(release): update v1.18.0 notes
Refresh the bilingual v1.18.0 catalog against the latest main-v2 release boundary, including #6155 channel updates and #6944 signing-gate hardening.

All required CI, CodeQL, release-notes, cache, and platform checks passed.
2026-07-27 11:14:44 +08:00
字多就没人取 7319949a6c fix(desktop): pin BranchMeta scope on new session to prevent project-to-global misclassification / 修复新建项目会话被错误归类为全局工作空间
When a user opens a folder, createEmptySessionFile writes the new
session's BranchMeta sidecar with an empty Scope field. During the
tab controller build, if resolveSessionBinding reaches
sessionBindingFromMeta, the empty Scope causes DefaultScope() to
return global, and applySessionBindingToTab overwrites the tab's
correct project scope. The wrong value is persisted to
desktop-tabs.json while desktop-projects.json remains correct.

The fix adds pinSessionBranchMeta, called immediately after
createEmptySessionFile, which writes the correct scope, workspace
root, and topic ID into the BranchMeta before the controller build
reads it — closing the empty-Scope window regardless of which
binding path is taken.

Closes #6914.
2026-07-27 08:21:36 +08:00
Yan Li 60c7b80e2c fix(cli): recover orphaned paste labels after session reload
When a session is reloaded (recovery chain, model switch, resume),
the in-memory pastedBlocks slice is cleared.  If the user later
re-submits an input that still references a [Pasted text #N] label
(via history navigation or quoted reply), expandPastedBlocks could
not find the matching block and the raw placeholder was sent to the
model — the model saw an unexpanded token instead of the pasted
content.

Recover orphaned labels by scanning conversation history for prior
submissions that contain the expanded --- Begin/End --- block and
re-expanding from there.  Labels with no recoverable content are
stripped so the model never receives a raw placeholder.

Ref: #6948
2026-07-27 07:32:23 +08:00
erphenheimer 2ec832eb3e ci: trigger re-run 2026-07-26 20:31:49 +08:00
erphenheimer d7509b5dc3 refactor(goal): extract intercept to goalAdvanceResult for FSM output purity
advance() previously wrote interceptMsg directly to the goalMachine
struct field as a side effect. Multiple FSM mechanisms (todo intercept,
self-check, idle detection) could each set interceptMsg, relying on
sequential ordering within the switch statement to avoid contention.

Extract intercept as an explicit field in goalAdvanceResult. The
caller (advanceGoalAfterTurn) stores it via a new setIntercept()
method, which takeIntercept() reads as before. This makes the data
flow explicit: advance() produces output, the caller routes it.

No behavior change. One theoretical edge case (auto-turn limit firing
simultaneously with an intercept) has a different interceptMsg state
after advance() returns, but the loop exits via cont=false immediately
so the intercept is never consumed and gets cleared on the next goal
lifecycle event.

Related to #6958 (FSM output model refinement).
The concrete bug #6956 was fixed separately in PR #6960.
2026-07-26 20:22:55 +08:00
erphenheimer 9885a86971 fix(goal): reset idleTurns on [goal:blocked] to prevent premature idle intercept
When the agent signals [goal:blocked], the goal FSM increments the
blocks counter but did not reset g.idleTurns. If blocks < 3 (so
notice remains empty) and the agent makes no tool calls for 2
subsequent turns, the idle detection at maxGoalIdleTurns fires and
sets an interceptMsg that overrides the agent's blocked signal.

Add g.idleTurns = 0 in the GoalStatusBlocked case so that an explicit
blocked signal resets the idle counter. The agent is not idling — it
deliberately signalled a blocking condition.

Fixes #6956
2026-07-26 20:10:19 +08:00
erphenheimer 6a1f0fcfaa fix(desktop): reattach detached runtime when rapidly switching sessions on same tab
When rapidly switching between saved sessions on the same tab, the old
session's runtime is detached (controller kept alive in detachedSessions)
if it was actively running. Switching back triggers a rebuild path that
tries to acquire an OS-level file lock already held by the detached
controller's file descriptor — LockFileEx with LOCKFILE_EXCLUSIVE_LOCK
on Windows prevents a second handle from the same process.

Fix: before entering the build path, check if the target session has a
detached runtime. If found, reattach it via attachExistingSessionRuntime
instead of building a new controller. This avoids the lease conflict
entirely and is more efficient.

Also fix canReclaimCurrentProcessSessionLease to check detachedSessions,
preventing incorrect reclaim attempts against detached runtimes.

Fixes #6955
2026-07-26 19:27:27 +08:00
wqshan-cn 8fc51c2c21 feat(desktop): add rich link icons 2026-07-26 19:14:39 +08:00
Yan Li 9f801723d9 fix(agent): skip byte-level prefix comparison when revision CAS proves same-runtime ownership
When baseState.revision == currentRevision the revision ledger proves
no external writer has touched the transcript.  In that case the
byte-level messagesHavePrefix comparison in checkSnapshotWrite is
redundant — the revision CAS already vouches for consistency — and its
false positives (normalisation / JSON round-trip / local-only metadata)
drive spurious diverged conflicts that cascade into recovery-branch
chains and force-saves that break the prefix-cache.

Future-tense (Delta): anchor on the revision CAS match, not the
byte-level disk snapshot (Gamma).  Same insight as Agda PR #8611
(makeTau's nTarget = nOld + nctel - 1).

Ref: #6027 Phase 3, #6948
2026-07-26 12:49:11 +08:00
SivanCola c846ad5559 Harden SignPath stable release gating (#6944)
Problem: Stable release v1.17.21 reached SignPath through release-stable.yml while the provider policy allowed only release-desktop.yml, blocking both Windows architectures after CLI and npm publication.

Root cause: The provider-side build-definition allowlist was maintained manually, and the stable orchestrator did not prove the complete signing path before starting public publishers.

Fix: Add a machine-readable signing contract, workflow call-graph validation, protected fingerprints, exact CODEOWNERS coverage, a zero-publication dual-architecture preflight, and fail-closed standalone attestation checks.

Verification: Full PR CI, CodeQL, Windows Desktop packaging, race, coverage, release workflow contract tests, cache guard, root and Desktop Go tests, vet, actionlint, npm dry build, and diff checks passed.
2026-07-26 09:51:34 +08:00
ikelvingo cab17d7a30 fix(openai): recognise Anthropic-style cache fields on OpenAI-compatible endpoints (#8171)
MiniMax uses Anthropic-style field names (input_tokens / output_tokens /
cache_read_input_tokens / cache_creation_input_tokens) on its
OpenAI-compatible endpoint instead of the standard OpenAI/DeepSeek
names. normaliseUsage() only recognised the latter, so MiniMax cache
stats were always zeroed out, causing the frontend to display
'cache not reported'.

Fix: add InputTokens, OutputTokens, CacheCreationInputTokens and
CacheReadInputTokens to wireUsage as fallbacks. normaliseUsage()
now resolves token counts and cache stats through a priority chain:
DeepSeek top-level -> OpenAI nested details -> Anthropic-style fields.
This benefits all OpenAI-compatible providers without provider-specific
branching.

Test: TestNormaliseUsageMiniMax covers cache-hit, cache-creation,
and DeepSeek field priority scenarios.
2026-07-25 23:59:22 +08:00
SivanCola 908ce880e5 Add Stable and Preview release channels / 新增 Stable 与 Preview 双发布渠道 (#6155)
Context:
Reasonix needed one public Stable/Preview contract across native CLI archives and Desktop updates while keeping npm prerelease tags independent.

Changes:
- Add protected native CLI Preview prereleases without moving Homebrew or GitHub Latest.
- Add persistent Desktop Stable/Preview selection, channel-aware update state, R2 pointers, and legacy Canary compatibility.
- Preserve the complete production Windows payload and installer signing chain and fail closed when signing or Preview R2 prerequisites are unavailable.
- Add release gateway routing, workflow contracts, compatibility coverage, and bilingual settings copy.

Compatibility:
Missing and unknown Desktop channel settings remain Stable. Legacy canary, beta, and next settings normalize to Preview. New Preview clients reject stale test-signed Canary manifests while older clients retain the mirrored canary pointer.

Verification:
Root and Desktop Go suites, frontend test:all and production build, crash-report Worker typecheck and 45 tests, release workflow contracts, cache guard, release notes tests, six native CLI target builds, CodeQL, race, and Windows NSIS/portable CI all pass on head 97623c982.
2026-07-25 14:05:00 +08:00
SivanColaandgithub-actions[bot] 75c18e41ec Prepare v1.17.21 release notes / 准备 v1.17.21 中英更新日志 (#6929)
* docs(release): prepare v1.17.21 notes

Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

* docs(release): complete v1.17.21 notes

Summary:
Expand the release surfaces beyond Desktop and add the missing CLI diagnostics and recovery ownership changes from PR #6912.

Verification:
Validated the release catalog, rendered the bilingual notes, ran the catalog tests, and checked the diff for whitespace errors.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-25 11:43:41 +08:00
JesonChouandSivanCola dcca564ec8 fix(desktop): fix code font not applied in chat blocks and file preview / 修复代码字体设置在对话区和文件预览中不生效 (#6925)
* fix(desktop): sync typography code-region when global mono font changes (#6907)

When the user switches the global monospace font in Appearance settings,
reset any previously-customised TypographySettings code-region override
(followGlobal=true) so the global choice takes effect in code blocks
instead of being silently blocked by the --typography-code-font inline
style set by applyTypographyPreferences.

Also replace three hardcoded font-family: monospace occurrences
(.remote-file-view__path, .remote-file-view__editor,
.remote-hostkey-dialog__fp) with var(--font-mono, monospace), and
align .code-lines-wrap with the other code-block rules by using
var(--typography-code-font, var(--font-mono)).

* fix(desktop): preserve regional code font overrides

Problem: Selecting a global monospace font silently forced an explicit Code & Tools typography override back to follow-global, while valid quoted preset font stacks were rewritten unnecessarily.

Root cause: The settings handler treated the regional override as stale, and the original patch assumed quoted multi-word family names were invalid after CSS custom-property substitution.

Fix: Keep regional typography independent when the global monospace choice changes and restore quoted multi-word font family names.

Verification: pnpm --dir desktop/frontend exec tsx src/__tests__/typography-preferences.test.ts; pnpm --dir desktop/frontend typecheck; git diff --check.

Co-authored-by: SivanCola <[email protected]>

* test(desktop): protect regional code font behavior

Problem: The code-font fix relied on CSS inheritance and settings ownership contracts that were not covered by focused regression tests.

Risk: A future global monospace handler could silently overwrite an explicit regional choice, or nested code elements could fall back to the browser monospace default again.

Coverage: Render Appearance with a customized code region and verify a global monospace change preserves its stored preference and CSS variable. Assert the nested code and line-number viewer font declarations directly.

Verification: pnpm --dir desktop/frontend exec tsx src/__tests__/settings-refresh-snapshot.test.tsx; pnpm --dir desktop/frontend exec tsx src/__tests__/typography-overflow-contract.test.ts; git diff --check.

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-25 10:56:21 +08:00
SivanCola cb8dcdc1f7 feat(desktop): add bounded regex search to code previews / 为代码预览添加有界正则搜索 (#6923)
* feat(desktop): add bounded regex search to code previews

Problem:
Workspace code previews only exposed literal search, the find shortcut was scoped to the focused viewer, and the floating search popup could obscure code.

Root cause:
JavaScript regular expressions run synchronously without cancellation, the workspace panel had no search action of its own, and the popup used absolute positioning inside the code viewport.

Fix:
Run opt-in regex searches in an inline worker with hard termination, debounce, stale-request cancellation, and pattern, source, and result limits. Add a visible workspace search action and panel-level shortcut, dock the search toolbar above code, preserve literal search by default, and localize bounded failure states.

Verification:
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend exec tsx src/__tests__/line-number-code.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-changes-errors.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-preview-css.test.ts
- git diff --check
- Browser verification at the narrow dock width

* Fix regex result handling and search toolbar copy

Problem: Valid regex patterns such as .* lost non-empty matches when they also emitted a zero-length result, and the floating copy button remained hidden under the open toolbar.

Root cause: Match collection rejected on the first zero-length result, while the copy affordance was rendered independently of search state.

Fix: Skip zero-length matches with Unicode-safe advancement, report unsupported only for zero-only patterns, move copy into the toolbar while search is open, and let narrow toolbar actions wrap as a group.

Verification: pnpm test; pnpm build; git diff --check; in-app browser verification at a 242 px code-pane width.

* fix(desktop): preserve pending code search requests

* fix(desktop): bound and clarify regex preview search

Problem: Regex worker startup could remain pending without entering the 200 ms timeout, and line-by-line evaluation silently changed regular-expression semantics.

Root cause: The deadline was created only after the async worker import resolved, while regex matching reset state for every source line.

Fix: Start the deadline before worker creation, scan regex requests against the full source with line anchors preserved, and surface explicit localized errors for newline-spanning matches. Add regression coverage for both paths.

Verification: node --import tsx src/__tests__/line-number-code.test.tsx; node --import tsx src/__tests__/workspace-changes-errors.test.tsx; node --import tsx src/__tests__/workspace-preview-css.test.ts; pnpm typecheck; pnpm test:typecheck; pnpm build; git diff --check.
2026-07-25 02:29:46 +00:00
JesonChouandSivanCola acdef9998a fix(desktop): paste text undoable via Ctrl+Z on Windows (#6531) / 修复ctrl+z无法撤回ctrl+v的内容 (#6571)
* fix(tui): add paste undo on Windows via Ctrl+Z (#6531)

On Windows Ctrl+Z has no job-control semantics, so repurpose it as
undo for paste operations. A LIFO undo stack (capped at 50 entries)
records the input state before each paste; Ctrl+Z pops and restores.
Folded pastes and image refs participate without double-pushing.

* fix(desktop): implement paste undo via explicit undo stack (#6531)

Replace the unreliable browser-native undo approach (execCommand /
setRangeText in WebView2) with an explicit paste undo stack in the
Composer. Before each paste the current input text is pushed onto a
ref-managed stack; a Ctrl+Z keydown handler pops and restores the
last saved state. Falls through to browser-native undo when the
stack is empty, preserving normal text-editing undo.

* Expose composer undo and redo controls

Problem:
Composer undo and redo behavior was not discoverable in Settings, help, context menus, or the user guides. YOLO tooltips also kept showing its default binding after a rebind.

Root cause:
The composer handled editing chords and Reasonix transactions locally instead of registering the actions in the shared shortcut catalog. The custom context menu exposed clipboard actions only, and documentation still described Ctrl/Cmd+Y as YOLO-only.

Fix:
Register locked platform undo and redo actions, protect their editing chords from legacy global bindings, reuse the transaction history from the context menu, render live YOLO shortcut labels, and update English and Chinese guidance.

Verification:
pnpm test:all
pnpm build
go test ./... in desktop
Real browser checks for Settings rows, live YOLO rebind labels, context-menu undo/redo, and a clean console

* Fix rich composer edit context menu

Problem:
The rich composer did not open Reasonix's edit context menu, so drafts containing skill or subagent tokens could not use the transaction-aware Undo and Redo actions.

Root cause:
Only the plain textarea wired the shared context-menu handler; RichComposerInput did not expose or forward a contextmenu event.

Fix:
Forward context-menu events from RichComposerInput and reuse the existing Composer edit menu for both editable surfaces. Add regression coverage for undoing and redoing a paste after an inline invocation.

Verification:
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm exec tsx src/__tests__/composer-session-draft.test.tsx
- pnpm exec tsx src/__tests__/composer-context-menu-clipboard.test.tsx
- pnpm test:typecheck
- pnpm build
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* Protect composer history from legacy YOLO bindings

Problem:
Legacy custom YOLO bindings on Ctrl/Cmd+Z or Ctrl/Cmd+Shift+Z intercepted
Composer undo and redo, toggling approval mode instead of editing the draft.

Root cause:
Composer matched the local YOLO shortcut before its transaction-aware history
handler and did not apply the reserved editing-chord policy used globally.

Fix:
Exclude reserved Composer undo and redo chords from local YOLO handling. Add
upgrade regression coverage for legacy bindings, rebound YOLO, and the default
Ctrl+Y behavior.

Verification:
- pnpm exec tsx src/__tests__/composer-session-draft.test.tsx (130 passed)
- pnpm exec tsx src/__tests__/keyboard-shortcuts.test.ts (71 passed)
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx (185 passed)
- pnpm exec tsx src/__tests__/shortcut-recorder.test.tsx (25 passed)
- pnpm test:typecheck
- pnpm exec vite build --logLevel error
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-25 02:25:28 +00:00
SivanCola 6e81c1c79f Fix sensitive data exposure in error logs / 修复错误日志敏感信息泄露 (#6924)
* fix: redact sensitive data from error logs

Problem: CodeQL reported two error logging paths that could expose credentials embedded in runtime errors.

Root cause: The heartbeat and bot controller failure paths logged raw error values, while the stronger authentication scrub was private to the control package.

Fix: Add shared credential and error redaction helpers, mask URL userinfo, reuse the helper for authentication errors, and sanitize both reported logging sinks.

Verification: go test ./...; cd desktop && go test ./...; git diff --check.

* fix: close redaction edge cases

Problem: URL userinfo containing raw @ characters could leave a password suffix visible, and repeated credential redaction changed already-redacted KEY=value output.

Root cause: The userinfo regexp stopped at the first @, while redactKeyValues remasked the stronger helper's **** marker as [redacted].

Fix: Consume the complete userinfo segment through the final @ and preserve existing redaction markers during key/value masking. Add regressions for raw @ proxy credentials and idempotent KEY=value errors.

Verification: go test ./...; git diff --check.
2026-07-25 10:06:21 +08:00
SivanCola e3c86bacf5 Fix Dependabot security alerts (#6922)
Problem:
The repository had twelve open Dependabot alerts across the site, desktop frontend, and Cloudflare Worker development toolchains.

Root cause:
Direct dependency ranges and generated lockfiles still resolved vulnerable releases of Astro, PostCSS, sharp/libvips, SVGO, DOMPurify, and js-yaml.

Fix:
Upgrade the site to Astro 7.1.3, refresh the affected frontend transitive dependencies, and move all Worker projects to Wrangler 4.114.0 with compatible workers-types 5.x releases.

Verification:
- Site tests and Astro static production build
- Desktop TypeScript check, Mermaid sanitizer tests, and Vite production build
- Worker TypeScript checks and Wrangler deployment dry-runs
- Crash-report Worker test suite
2026-07-25 08:07:59 +08:00
SivanColaandSivanCola aa8c97f75e feat: Add deterministic planner routing and depth / 增加确定性规划路由与深度 (#6918)
* feat: add deterministic planner routing and depth

Problem:
The two-model coordinator invoked the planner for nearly every work-shaped turn, adding latency to questions, contextual follow-ups, and clearly atomic edits while producing plans with inconsistent engineering detail.

Root cause:
The planner gate returned only a boolean and inferred intent from the composed prompt, so it could not represent planning depth, host-owned approval, trusted turn state, or observable routing reasons.

Fix:
Introduce deterministic executor-only, plan-and-execute, and plan-for-approval routes with light/full depth and privacy-safe reason codes. Feed the gate pristine user text plus trusted Goal, Delivery, Plan Mode, synthetic, and conversation metadata. Add stable planner contracts, bounded per-turn research, host-enforced plan approval, executor validation guidance, documentation, and regression matrices.

Verification:
- ./scripts/cache-guard.sh
- go test ./internal/control ./internal/agent ./internal/boot -count=1
- go vet ./internal/control ./internal/agent ./internal/boot
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- focused race tests for planner routing, metadata, approval, and research budgets

Cache impact:
Low. The planner system prompt changes once to define stable light/full contracts; per-turn route and depth remain in the user turn. Tool schemas and ordering are unchanged, and the cache guard remains above its 90 percent threshold.

* fix: preserve planner handoff semantics

Problem:
Bare plan-first requests were classified as approval-only turns, so they could stop after planning instead of reaching the executor.

Root cause:
The deterministic directive table conflated plan-and-execute, plan-for-approval, and explicit no-execution intent.

Fix:
Add a distinct plan-only route, reserve plan-for-approval for explicit confirmation language, route bare plan-first requests through the executor, and preserve both execution boundaries on planner failure. Update English and Chinese contracts plus regression coverage.

Verification:
- go test ./internal/agent ./internal/control
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- ./scripts/cache-guard.sh

* fix(planner): preserve execution after bounded planning

Problem: Task-first plan-only and approval boundaries could be missed, while a planner research-budget pause stranded ordinary work without a usable executor continuation.

Root cause: Routing only recognized leading planning directives, and Coordinator preserved tool-loop pauses even though planner research limits are host-owned per-turn policy.

Fix: Parse unquoted execution boundaries across the request with negation handling; roll back incomplete planner turns; fall back to the executor with the original task for ordinary plan-and-execute while keeping plan-only and approval routes fail-closed. Update docs and regression coverage.

Verification: go test ./... -count=1; go test -race ./internal/control ./internal/agent -count=1; go vet ./...; ./scripts/cache-guard.sh; live Planner-to-Executor tests with DeepSeek, LongCat, and GLM Coding Plan.

Co-authored-by: SivanCola <[email protected]>

* test(planner): verify plan-only continuation

Problem: Plan-only coverage proved that execution was suppressed and the plan was persisted, but did not prove that a later user command actually reached the executor with that saved plan.

Fix: Add a two-turn coordinator regression that plans without execution, then sends an execution continuation and verifies the executor receives the persisted plan while the planner is not invoked again.

Verification: go test ./internal/agent -count=1

Co-authored-by: SivanCola <[email protected]>

* fix(planner): skip bounded read-only actions

Problem: Short read-only commands such as running tests, reviewing a PR, or inspecting one file still invoked the planner and could be upgraded to a full plan in Delivery mode.

Root cause: Read-only verbs shared the generic work-term bucket with mutations, and anchored work was evaluated before ambiguous scope.

Fix: Detect requests containing only read-only work terms and route bounded non-ambiguous cases directly to the executor. Preserve full planning for high-risk, cross-surface, structured, complex, or ambiguous read-only work, and prioritize ambiguous scope over an anchored target. Update English/Chinese docs and the routing matrix.

Verification: go test ./... -count=1; go test -race ./internal/control ./internal/agent -count=1; go vet ./...; ./scripts/cache-guard.sh.

Co-authored-by: SivanCola <[email protected]>
2026-07-25 07:56:30 +08:00
SivanCola 87999b9747 Sign every Windows payload executable / 签署全部 Windows 载荷程序 (#6904)
* fix(release): sign Windows payload executables

Problem:
Windows Defender can quarantine reasonix-desktop.exe after install or update even though the downloaded NSIS installer is signed.

Root cause:
The release workflow applied Authenticode only to the finished NSIS container. The desktop executable, support binaries, portable archive entries, and generated uninstaller remained unsigned after extraction.

Fix:
Sign the exact Windows payload through a versioned SignPath configuration, rebuild the portable archive and NSIS installer from those signed files, verify the payload before signing the outer installer, and fail official releases closed when signing is unavailable. Preserve and inject a signed NSIS uninstaller in the second packaging pass.

Verification:
- go test ./... (desktop module)
- env -u DEEPSEEK_API_KEY go test ./...
- bash scripts/release-workflows.test.sh
- ./scripts/cache-guard.sh
- actionlint release workflows
- bash syntax and git diff checks

* fix(release): separate canary installer signing config

* fix(release): automate gated SignPath approvals

Problem:
Release certificates require one SignPath approval per request, causing four extra manual approval actions after the GitHub release gate.

Root cause:
The desktop workflow submitted each request synchronously and waited for an interactive SignPath approver even though the same release authority had already approved the protected GitHub environment.

Fix:
Submit requests asynchronously, validate the expected signing policy, approve them through the dedicated CI identity, poll to a final successful state, and securely download the signed artifacts. Exercise the same path in canary with an approval-enabled test policy and document the administrator contract.

Verification:
- cd desktop && go test ./...
- bash scripts/release-workflows.test.sh
- actionlint release-stable.yml release-desktop.yml
- git diff --check

* fix(release): default SignPath API endpoint

The GitHub connector does not expose a SignPath API URL when requests are submitted asynchronously. Use SignPath's documented SaaS endpoint by default and keep the script override available for self-hosted installations.

Verified with desktop Go tests, release workflow contract tests, actionlint, and git diff --check.

* fix(release): restrict production signing origin

Problem:
Repository-level SignPath credentials were available to workflows on ordinary repository branches, while the production signing policy allowed every branch origin. A writer could therefore construct an unreviewed workflow that requested a trusted production signature.

Root cause:
Stable and prerelease builds ran directly from tag origins, so the SignPath policy could not use one exact protected branch without breaking tag releases. The policy had consequently been configured with an unrestricted branch wildcard.

Fix:
Relay stable and desktop prerelease tags to the current workflow on protected main-v2, keep immutable tags as build candidates, add a gated non-publishing production-signing smoke mode, and codify the exact main-v2 SignPath origin in tests and administrator documentation.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint v1.7.12 on all changed workflows
- cd desktop && go test ./...
- cd desktop && go test . -run signing contract tests
- ./scripts/cache-guard.sh
- git diff --check

* fix(release): wait for manual smoke approval

Problem:
The first production-certificate smoke run failed immediately after submitting its two payload requests when the CI identity had intentionally been removed from the production approver list.

Root cause:
The request completion helper always called the SignPath Approve API as soon as a request entered WaitingForApproval, even in the explicitly human-reviewed smoke mode.

Fix:
Add an external-approval mode that polls without approving, enable it only for production signing smoke runs, and document and test the manual verification boundary. Normal canary and release automation retains CI approval.

Verification:
- bash scripts/release-workflows.test.sh
- actionlint v1.7.12 on release-desktop.yml
- cd desktop && go test . -run signing contract tests
- git diff --check
2026-07-25 06:53:53 +08:00
JesonChouandSivanCola a3e0f10be4 fix(desktop): fix code preview line-number alignment and redesign search bar / 修复代码预览行号对齐并重构搜索栏 (#6910)
* fix(desktop): redesign code preview search as Codex-style floating popup, fix line-number gutter alignment / 重构代码预览搜索为 Codex 风格浮动弹窗,修复行号对齐 (#6906)

- Fix line-number gutter cutoff caused by box-sizing: border-box conflicting
  with minWidth on .code-line-ln — add box-sizing: content-box so the ch-based
  minWidth only constrains content area, not padding+border.

- Replace the full-width inline search bar with a floating popup positioned
  at top-right (absolute, 42px from top), matching Codex visual style:
  rounded corners (8px), box-shadow, compact layout (gap:2px, padding:3px 6px).

- Add search icon (magnifying glass SVG) before the input field.

- Add regex toggle (*) alongside existing Aa (case) and ab (whole-word)
  toggles. Invalid regex patterns degrade gracefully to no results.

- Add workspace.searchNoResults and workspace.searchUseRegex i18n keys
  for en/zh/zh-TW.

- Bump search popup z-index to --z-local-handle (4) so the close button
  renders above the copy button.

- Remove obsolete code-block__wrap--search-open copy-button offset rules.

* fix(desktop): address code review feedback for code preview search / 修复代码审查反馈:搜索输入框宽度、正则-全词互斥、翻译和测试覆盖 (#6906)

- Change search input from fixed 120px to flex layout (min 80px, max 240px)
- Make wholeWord and useRegex toggles mutually exclusive
- Improve zh-TW translation: '正則' -> 'Regex'
- Add test coverage for regex search, invalid regex, and useRegex+wholeWord

* fix(desktop): make search popup width adaptive to prevent overflow past code area / 搜索弹窗宽度自适应,防止超出代码可视区域

- Add max-width: calc(100% - 16px) to .code-search to constrain within container
- Change input to flex: 1 1 60px with min-width: 60px, remove fixed max-width
- Input now shrinks when space is tight instead of overflowing into the file tree

* Keep code preview search literal and responsive

Problem:
The PR exposed arbitrary JavaScript regular expressions on the React render path, allowing expensive patterns to block the WebView and zero-width Unicode matches to return duplicate results.

Root cause:
The regex toggle widened the existing escaped literal-search contract without an isolated, time-bounded execution path.

Fix:
Remove the regex mode and its UI/localization surface while preserving the floating search popup, case matching, whole-word matching, navigation, and no-results feedback. Add a regression test proving regex metacharacters remain literal search text.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/line-number-code.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-preview-css.test.ts
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- Real-browser code preview search, layout, close, and console checks

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-24 23:56:09 +08:00
SivanCola dadce78d0a Fix CLI TUI diagnostics and recovery ownership / 修复 CLI TUI 日志与恢复所有权 (#6912)
* fix(cli): isolate TUI diagnostics and recovery ownership

Problem:
Background structured logs and plugin stderr could overwrite the Bubble Tea composer. Recovery branches could also continue without the CLI session lease, and post-autosave metadata rewrites could be misclassified as same-revision conflicts.

Root cause:
The interactive CLI left the process logger and initial boot stderr attached to the terminal, did not wire OnSessionRecovered to its lease keeper, and classified some in-place session mutations separately from the mutation itself.

Fix:
Route interactive diagnostics through a private bounded log, propagate the diagnostic writer and recovery callback through every controller build, move the lease before recovery commits, and make persisted-history rewrites atomic.

Verification:
- go test -race ./internal/cli ./internal/control ./internal/agent ./internal/guardian
- env -u DEEPSEEK_API_KEY go test ./...
- go build ./cmd/reasonix
- Windows amd64 test-binary cross-compile

* fix(cli): preserve recovery lease across all runtimes

Problem:
The recovery lease callback was only wired into interactive chat. Headless run and serve sessions could move to a recovery branch while still guarding the original path, and serve controller rebuilds could publish a post-snapshot recovery path before its lease followed.

Root cause:
Session recovery ownership lived in a CLI-specific helper, serve attached its keeper only to explicit HTTP binding operations, and model switches rebound the pre-snapshot path after publishing the replacement controller.

Fix:
Move the recovery handoff onto SessionLeaseKeeper, install it for chat, run, serve, and rebuilt serve controllers, and bind the replacement controller's actual post-snapshot path before publishing it. Preserve the outgoing controller on binding failure and keep lease errors path-free.

Verification:
- go test -count=1 ./internal/control ./internal/cli ./internal/serve
- go test -race -count=1 ./internal/control ./internal/cli ./internal/serve
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && go test -count=1 ./...
- go vet ./...
- go build ./cmd/reasonix
2026-07-24 23:29:32 +08:00
SivanCola 72a1e32b49 Fix cross-platform plugin Hook execution contracts / 修复跨平台插件 Hook 执行契约 (#6911)
* Fix plugin hook execution contracts

Problem: Windows plugin hooks with compound commands or nested quoting could still fail after the narrow quoted-batch compatibility fix, while explicit empty args could lose exec-form semantics across JSON round trips.

Root cause: Hook launch behavior was inferred from argument length and platform shell heuristics instead of preserving the manifest's exec-form or shell-form contract. CMD and PowerShell command strings also passed through incompatible quoting layers.

Fix: Preserve args field presence and shell selection through plugin parsing and loading, add explicit legacy/exec/shell execution modes, transport raw CMD and encoded PowerShell scripts without token re-rendering, retain the existing simple batch compatibility path, and document the cross-platform contract.

Verification: Focused Hook and plugin package tests, race and shuffled tests, fuzzing, root and Desktop full suites, go vet, and Windows/Linux cross-compilation.

* Fix Windows hook output regressions

Problem: The new compound CMD regression expected cmd.exe to strip quotes from %1, leaving the Windows job red. PowerShell hook scripts also preserved UTF-16LE input transport without forcing captured output to UTF-8.

Root cause: The test confused %1 with the dequoting %~1 form, and the Hook-specific EncodedCommand path did not reuse the shell runtime's existing PowerShell output-encoding prologue.

Fix: Align the CMD assertion with native batch semantics, share the PowerShell UTF-8 script wrapper across shell and Hook launchers, and exercise Chinese plus emoji output in the Windows runtime test.

Verification: Focused Hook/plugin/sandbox tests and vet; race/shuffle count 3; root and Desktop full suites; full vet; Windows amd64 test cross-compilation; PowerShell encoding fuzzing.
2026-07-24 23:07:09 +08:00
SivanCola 4b2ef3dabb feat(desktop): add native crash diagnostics / 增加桌面原生崩溃诊断 (#6900)
* feat(desktop): add native crash diagnostics

Problem:
Windows users could report that the desktop closed and would not reopen, but native process exits, UI-thread stalls, WebView2 failures, and window-restore hangs left too little durable evidence for analysis.

Root cause:
The desktop only persisted panics from explicitly wrapped goroutines in one overwrite-prone file. Startup tracking stopped distinguishing a healthy process that later vanished, and Windows native/WebView2/window-restore failure paths were not connected to the diagnostic pipeline.

Fix:
Capture Go runtime fatal output, retain a bounded backward-compatible report queue, heartbeat startup lifecycle state, and attribute abnormal exits to coarse uptime, install, and update buckets. Add a Windows Wails-window watchdog, WebView2 process-failure classification, and durable window-restore journaling. Extend aggregate metric ingestion and the health dashboard, while redacting panic values, secrets, and personal path segments and documenting the telemetry behavior.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race . -run focused crash queue tests
- go test -race ./internal/repair -run TestStartupTracker
- Windows amd64 cross-compile of the desktop test binary
- desktop frontend typecheck, focused settings tests, and production build
- crash-report worker: 42 tests and TypeScript typecheck
- git diff --check

* test(desktop): make hang assertions platform-aware

Problem: Windows desktop CI expected macOS crash labels and metric buckets even though the watchdog correctly emits Windows-specific diagnostics.\n\nFix: derive the expected label, error type, and metric bucket from the same platform identity helpers used by the implementation.\n\nVerification: targeted macOS tests pass; Windows amd64 test binary cross-compiles successfully.

* fix(desktop): remove crash watchdog stalls

Problem: the Windows UI watchdog allocated a permanent syscall callback on every one-second heartbeat, and the window-restore journal used a crash-safe fsync/retry path immediately before showing the window.

Root cause: Windows callbacks cannot be released and the Go runtime has a finite callback table; AtomicWriteFile can also wait on antivirus-held rename locks.

Fix: register one serialized EnumWindows callback for the process, keep the restore journal best-effort without fsync/retry, and add long-run Windows plus journal regression coverage.

Verification: go test ./... (desktop); targeted race tests; go vet ./...; Windows amd64 cross-compile; cache guard.

* fix(desktop): make crash diagnostics process-safe

Problem:
Window restore diagnostics recorded success after Wails only queued UI-thread work, and routine duplicate launches shared the primary process's fatal-output file.

Root cause:
The restore timeout watched the WindowShow call instead of native window state, while fatal output used one process-global path before Wails enforced its single-instance lock.

Fix:
Wait asynchronously for the Windows window to become visible and non-minimised, fence superseded restore attempts, and retain the journal until a real acknowledgement. Store fatal output and structured-panic markers per PID, process only dead owners, and keep backward-compatible legacy dump ingestion.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race .
- cd desktop && go vet ./...
- Windows amd64 desktop test cross-compile
- ./scripts/cache-guard.sh
- git diff --check
2026-07-24 23:04:59 +08:00
SivanColaanderphenheimer a6e1459621 Fix Desktop session runtime ownership / 修复桌面会话运行时所有权 (#6897)
* fix(desktop): canonicalize runtime process identity

Problem: executable-based single-instance IDs let stable, canary, and portable builds concurrently write the same Reasonix home, while path aliases could split session lease identity.

Root cause: process ownership was keyed by binary location and fresh paths resolved only their immediate parent.

Fix: hash the canonical REASONIX_HOME and resolve every missing path component through the nearest existing symlink or junction ancestor, preserving isolated homes and dev-mode parallelism.

Verification: Go agent tests, Desktop tests, Windows cross-compilation, and canonical-path race tests pass.

* fix(desktop): unify writable session runtime ownership

Problem: Goal and Delivery mode rebuilds, duplicate tab restore, background reattachment, and saved-session navigation could build a second controller for one session or tear down the live source before a target was proven usable.

Root cause: controller startup, lease ownership, deferred retries, detached-session attachment, and path switching were coordinated per WorkspaceTab without a canonical process-wide admission record or one atomic controller/lease/path commit.

Fix: add a SessionRuntime registry keyed by runtime ID and canonical session key; attach or wait on existing local runtimes; safely reclaim only orphaned current-process leases; and make saved-session switches build, restore, validate, and lease a candidate before atomically publishing it. Target failures preserve the source controller, lease, path, mode profile, and epoch.

Verification: full Desktop and repository tests, Desktop/Agent/Controller/lease race suites, all 18 collaboration-token-mode orderings, deterministic A/B/A navigation fencing, frontend tests/build, Wails bindings, and a production Desktop build pass.

Co-authored-by: erphenheimer <[email protected]>

* fix(frontend): fence sends and events by runtime state

Problem: the composer could append an optimistic user bubble while a workspace was starting or lease-blocked, and late events from a replaced controller could mutate the new transcript.

Root cause: frontend readiness used only legacy booleans and controller events had no process-runtime generation fence.

Fix: expose structured runtime phase and issue metadata, gate every submit path before optimistic dispatch, seed and advance per-tab runtime epochs, and ignore events from obsolete epochs while retaining legacy event compatibility.

Verification: the full frontend suite, focused send-failure tests, TypeScript checking, and the production Vite build pass.

* docs: record desktop session ownership fix

Problem: the user-visible impact and compatibility boundary of the Desktop self-lock fix were not documented.

Fix: describe single-runtime session ownership, epoch-fenced events, readiness-gated sends, and REASONIX_HOME-scoped single-instance behavior in the Unreleased changelog.

Verification: Markdown diff inspection and repository privacy scan pass.

* test(desktop): align CI fixtures with runtime ownership

Problem: CI found an unused private runtime helper and a Linux timing path where a Heartbeat test injected Ctrl plus Ready without publishing the matching SessionRuntime state.

Fix: remove the unused helper and advance the injected test runtime exactly as the production controller publication path does; production readiness checks remain strict.

Verification: the Heartbeat submission and pending-prompt tests pass for 20 repetitions and for 5 race-enabled repetitions, and go vet passes.

* fix(desktop): wait for starting session runtimes

Problem: two persisted tabs restoring the same session could attach the first tab's starting placeholder before its controller was published, retire both candidate builds, and leave the visible session permanently starting.

Root cause: startup called the general attach path before claimSessionRuntime classified the registered phase, and applyRuntimeTab could create a second registry record when adopting a legacy runtime into a target that already owned a placeholder.

Fix: make claimSessionRuntime the startup admission entry, refuse attachments from non-ready runtime owners, reuse the target placeholder when adopting compatible visible or detached runtimes, and cover the starting-to-ready duplicate-tab handoff.

Verification: focused runtime tests passed for 10 repetitions; the runtime/mode/navigation race matrix passed for 3 repetitions; Desktop go test ./..., go test -race ., go vet ./..., go build ./..., and git diff --check passed.

---------

Co-authored-by: erphenheimer <[email protected]>
2026-07-24 16:13:54 +08:00
SivanCola 2c1df5b4f9 Trust configured MCP and Hooks by default / 默认信任已配置的 MCP 与 Hooks (#6889)
* fix(mcp): trust and surface project servers

Problem:
Project-provided MCP servers were blocked behind a separate launch authorization flow and were not clearly distinguished from global servers in Desktop or CLI management views.

Root cause:
Project scope was coupled to launch approval, while configured-server lookup and presentation did not preserve project provenance consistently.

Fix:
Treat project MCP configuration as user-authorized by default, preserve workspace-scoped activation and relative working directories, load project config for reconnects, and group project, global, and managed servers consistently across Desktop and CLI.

Verification:
- ./scripts/cache-guard.sh
- go test ./...
- cd desktop && go test ./...
- pnpm build
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts

* fix(mcp): preserve config scopes and precedence

Problem:
Global MCP installs and project declarations shared persistence paths, so Desktop and CLI actions could write the wrong config, store activation against the wrong workspace, or remove same-name declarations across scopes.

Root cause:
Runtime config merging preserved the effective entry but persistence call sites did not consistently carry its owning source or the controller workspace.

Fix:
Write explicit installs to the user-global config, keep project edits in reasonix.toml or .mcp.json, apply deterministic project-over-global precedence, and restore the next same-name fallback after removing an override. Align Desktop, CLI, TUI, imports, Planner-facing runtime state, and documentation with the same rules.

Verification:
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- cd desktop/frontend && pnpm run typecheck
- cd desktop/frontend && pnpm build
- ./scripts/cache-guard.sh
- git diff --check

* test(mcp): satisfy staticcheck in handler

Problem:
The PR lint job rejected the new MCP test handler with staticcheck QF1003.

Root cause:
The handler used an if/else-if chain on the same request method value.

Fix:
Use a tagged switch for initialize and tools/list responses without changing test behavior.

Verification:
- go test ./internal/control
- git diff --check

* feat: trust configured project hooks by default

Problem:
Project hooks required a separate workspace trust action even though configured MCP servers and installed plugin hooks already use low-friction trusted defaults. This added a second confirmation model and left Desktop, CLI, diagnostics, and documentation describing a trust state users should not need to manage.

Root cause:
Hook loading and inspection were gated by a persistent trust.json store, with that state propagated through boot, slash commands, Wails settings, diagnostics, and compatibility payloads.

Fix:
Load project hooks automatically whenever a project root is present, while preserving Safe Mode as the emergency disable path. Remove the trust store and user-facing trust controls, and keep legacy CLI commands, Wails methods, LoadOptions.Trusted, and trusted_project JSON as backward-compatible no-ops or enabled values. Update tests, diagnostics, localization, and documentation to describe automatic loading.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- pnpm typecheck
- pnpm check:css
- pnpm exec tsx src/__tests__/diagnostics-settings.test.tsx
- ./scripts/cache-guard.sh
- Desktop browser validation for project Hooks
- git diff --check

* fix: keep MCP auth cleanup in the active workspace

Problem: The TUI clear-auth action followed the process working directory instead of the active controller workspace, and Desktop CI was blocked by an orphaned helper.

Root cause: Authentication cleanup bypassed the workspace selection used by the MCP manager, while the removed project-trust flow left an unused Desktop path helper behind.

Fix: Centralize TUI MCP workspace selection, add a cross-workspace regression test, remove the unused helper, and document the executable repository-config boundary with Safe Mode recovery guidance in English and Chinese.

Verification: go test ./internal/cli; go vet ./...; go test ./...; cd desktop and run go vet ./..., go build ./..., and go test ./...; ./scripts/cache-guard.sh; git diff --check.

* fix(mcp): preserve advertised cold-cache connect calls

Problem:
A provider could call a cold-cache MCP connect placeholder that had been advertised at request serialization time, but background schema discovery could replace it before dispatch and produce an unknown-tool failure.

Root cause:
Tool calls are resolved against the current registry after the provider returns, while cache-miss discovery intentionally swaps the synthetic connect placeholder for the server's real tools.

Fix:
Recognize a stale synthetic connect call as completed when the same MCP namespace now exposes real tools, and let already-resolved lazy placeholders report the same controlled result. Keep genuine unknown calls unchanged and add regressions at both dispatch and lazy-lifecycle boundaries.

Verification:
- go test ./...
- go test -race ./internal/plugin ./internal/agent
- go vet ./internal/plugin ./internal/agent
- ./scripts/cache-guard.sh
- delayed cold-cache LongCat MCP integration test
2026-07-24 16:00:36 +08:00
jiangrenhang 2630a3e7aa fix(provider): skip image_url content parts for DeepSeek models / DeepSeek 模型跳过 image_url 格式
DeepSeek API rejects image_url content parts with HTTP 400
"unknown variant 'image_url', expected 'text'". When the DeepSeek
protocol is detected, convert image attachments to text references
("[Attached image: filename.png]") instead of image_url parts.

This prevents the 400 error while still informing the model that
images were attached. Tool-result images are also skipped for
DeepSeek since they would produce the same error.

Fixes #6682.
2026-07-24 15:55:37 +08:00
SivanCola 7663495c91 fix(desktop): keep provider retries cancellable / 保持桌面端重试可停止 (#6902)
* fix(desktop): keep provider retries cancellable

Problem: During provider retry backoff, a stale idle tab snapshot could clear the frontend running state, hiding Stop until all retry attempts finished.

Root cause: The retrying event only updated retry counters even though it is authoritative evidence that the foreground turn remains active and cancellation-aware.

Fix: Restore running, turn-active, cancellable, and timing state when handling retrying events, with a regression that reproduces the stale snapshot ordering.

Verification: Focused controller progress, cancel reconciliation, and composer tests pass; frontend typecheck, CSS checks, and git diff checks pass.

* fix(desktop): fence retry state from stale snapshots

Record retry activity on the same monotonic clock used by runtime snapshot fetches. Ignore idle snapshots started before the latest retry so Stop and Escape cancellation stay available, while allowing a fresh idle snapshot to reconcile a missed turn_done and clear the retry indicator.
2026-07-24 15:53:09 +08:00
SivanCola deb679bfcf Reduce Auto failure interruptions / 减少 Auto 失败恢复打断 (#6896)
* fix(recovery): reduce Auto failure interruptions

Problem:
Auto injected a long guard message after ordinary tool and verification failures, then treated unrelated follow-up work as recovery that could reach the reviewer or a user confirmation. Recovery pause copy could also appear duplicated or in the wrong language.

Root cause:
The recovery gate treated an active failure as a task-wide boundary, did not distinguish transient timeouts, and allowed non-plan reviewer outcomes to escalate to human prompts. Localized clients also rendered the backend English fallback alongside localized copy.

Fix:
Classify failure evidence, isolate retries to the exact failed operation, keep unrelated and host-proven read-only work automatic, and reserve human decisions for structured plan changes. Add bounded timeout guidance, backward-compatible persistence, localized recovery_paused copy, and matching desktop, CLI, serve, and documentation coverage.

Verification:
- go test ./... -count=1 with DeepSeek credentials unset
- go test -race ./internal/recovery ./internal/control -count=1
- go vet ./internal/recovery ./internal/agent ./internal/control
- ./scripts/cache-guard.sh
- go test ./... -count=1 in desktop/
- pnpm typecheck and focused frontend recovery tests

* fix(recovery): enforce runtime retry boundaries

Problem:
Runtime-shaped recovery proposals always carried a preview, so their fingerprint did not match completed observations and the exact-operation failure limit was not enforced. Tool-owned MCP deadlines were also reported as cancellations and skipped transient recovery classification.

Root cause:
One fingerprint served both operation accounting and preview-bound approvals, despite those records having different shapes. The Agent adapter also inferred cancellation from the returned error cause instead of the parent turn context.

Fix:
Separate preview-free operation identity from preview-bound approval identity, and treat a deadline as cancellation only when the parent context ended. Add regressions for real proposal previews, internal MCP deadlines, and parent deadline preservation.

Verification:
- go test ./... -count=1 with DeepSeek credentials unset
- go test -race ./internal/recovery ./internal/agent ./internal/control -count=1
- go vet ./internal/recovery ./internal/agent ./internal/control ./internal/cli ./internal/serve
- ./scripts/cache-guard.sh
- go test ./... -count=1 in desktop/
- focused frontend recovery tests and pnpm typecheck
2026-07-24 14:20:59 +08:00
SivanCola 1ae7e48ba6 Streamline stable release preparation and prepare v1.17.20 notes / 精简稳定版准备流程并准备 v1.17.20 发布说明 (#6893)
* docs(release): prepare v1.17.20 notes

Summary:
Add reviewed bilingual stable release notes for v1.17.20, covering the machine CLI interfaces, trusted MCP delegation, Debian updater bootstrap, recovery changes, and desktop reliability fixes.

Compatibility:
Document the one-time Debian package bootstrap, matching Desktop/Remote host requirement, machine identity initialization, and installed-MCP trust boundary without changing runtime behavior.

Verification:
- node scripts/release-notes.mjs validate
- rendered the v1.17.20 GitHub release notes successfully
- node --test scripts/release-notes.test.mjs
- bash scripts/release-workflows.test.sh
- git diff --check

* ci(release): reuse release-bound pull requests

Problem:
Preparing reviewed bilingual notes opened a dedicated pull request for every stable version even when an existing final product or release-infrastructure pull request already defined the candidate boundary.

Root cause:
The preparation workflow always selected a version-specific release-notes branch and had no validated way to update an existing review branch.

Fix:
Add an optional target_pr path that accepts only an open same-repository pull request into main-v2 containing the latest trunk. Reuse that branch, expose the rendered draft in the workflow summary, retain the dedicated pull request as a fail-closed fallback, and document the release-PR frequency rule.

Verification:
- bash scripts/release-workflows.test.sh
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- rendered the v1.17.20 release notes successfully
- git diff --check
2026-07-24 12:03:50 +08:00
mchenziyiandSivanCola 16d71aa475 feat(cli): add redacted machine interfaces / 增加脱敏机器接口 (#6859)
* feat(cli): add redacted machine interfaces

* fix(cli): repair machine lifecycle projections

Problem:
Persisted running jobs could be resurrected after restart with a closed done channel and no cancel function, while task projections reported running subagents as finished and artifact-complete. Machine session IDs also exposed model-bearing transcript names.

Root cause:
Writing start metadata invalidated the loader's terminal-only assumption, and the machine projection treated update timestamps and file allocation as terminal completion.

Fix:
Normalize restored running jobs to an interrupted terminal state, keep in-flight artifacts incomplete, project stale state from the session lease, verify subagent artifact presence, hash machine session identities, reject conflicting event-output flags, and document the schema contract in English and Chinese.

Verification:
- go test ./internal/jobs ./internal/cli
- go test -race ./internal/jobs
- go test -race ./internal/cli -run machine-interface focused tests
- go vet ./internal/jobs ./internal/cli
- ./scripts/cache-guard.sh
- root test sweep passed all deterministic packages; the invalid-key live probe was isolated and its package passed with DEEPSEEK_API_KEY unset

* fix(jobs): preserve live tasks across runtime rebuilds

Problem:
A replacement jobs manager could rewrite a still-running background task as interrupted while the original controller continued executing it. CLI and serve runtime switches could trigger that overlap because they only guarded foreground turns.

Root cause:
Persisted running artifacts did not identify their live manager, so every loader assumed they belonged to a dead runtime. Runtime rebuild entry points also used inconsistent active-work checks.

Fix:
Add a private optional manager owner ID to job metadata, defer artifacts owned by a live in-process manager, and reload them after terminal completion. Normalize model, effort, skill, work-mode, and serve rebuild guards to reject foreground work, pending prompts, or background jobs.

Verification:
- go test ./...
- go test -race ./internal/jobs
- go test -race ./internal/cli ./internal/serve
- go vet ./internal/jobs ./internal/cli ./internal/serve ./internal/i18n
- ./scripts/cache-guard.sh

* fix(jobs): require session ownership for stale repair

Problem:
A manager loading persisted running job artifacts could rewrite them as interrupted while another Reasonix runtime still owned and wrote the same transcript. Bot and Remote Workbench sessions also did not retain transcript leases across their full lifecycle.

Root cause:
Process-local manager owner IDs cannot prove that a missing owner is dead in another process, and the artifact loader had no session-write ownership check. Bot and Remote create, restore, fork, rotate, and close paths did not consistently acquire, transfer, and release session leases.

Fix:
Inject a fail-closed session ownership probe into jobs managers, repair abandoned running artifacts only while the current process holds the transcript lease, and keep leases attached to Bot and Remote sessions until controller teardown. Add regression coverage for denied repair, lease transfer, concurrent restore, and close-time release.

Verification:
- go test ./internal/jobs ./internal/agent ./internal/control ./internal/bot ./internal/remote/workbench/runtime ./internal/boot
- go test -race ./internal/jobs ./internal/agent ./internal/bot ./internal/remote/workbench/runtime
- go vet ./internal/jobs ./internal/agent ./internal/control ./internal/bot ./internal/remote/workbench/runtime ./internal/boot
- go test ./... (all deterministic packages passed; the invalid local DeepSeek credential probe passed with DEEPSEEK_API_KEY unset)
- cd desktop && go test ./...
- ./scripts/cache-guard.sh

* test(remote): release session leases before cleanup

Problem:
Windows CI could not remove Remote runtime TempDir trees because two tests ended while the new session lease lock files were still open.

Root cause:
The tests created or rotated leased runtime sessions but never closed their Server instances before testing.TempDir cleanup. Unix permits unlinking open files, while Windows correctly rejected the cleanup.

Fix:
Register snapshotAndClose cleanup for the rotation fixture and broker integration test so controllers close and session leases release before temporary directories are removed.

Verification:
- go test ./internal/remote/workbench/runtime -run 'TestSessionRotationRegistryFailureReturnsCommittedEpoch|TestRuntimeControllerUsesDesktopBrokerWithoutHostKey' -count=1
- GOOS=windows GOARCH=amd64 go test -c -o /tmp/pr6859-runtime.test.exe ./internal/remote/workbench/runtime
- git diff --check

* fix(cli): preserve legacy run session IDs

Problem:
Existing json and stream-json automation received a hashed session_id, so the documented resume query could no longer resolve the value emitted by a run.

Root cause:
runAgent applied the new redacted machine-session transform before finalizing every structured output format instead of limiting it to events-jsonl.

Fix:
Select the final session ID by output format: preserve the established raw branch ID for text, json, and stream-json, and hash only events-jsonl. Add focused compatibility coverage for every format.

Verification:
- go test ./internal/cli -run 'TestRunOutput(SessionIDPreservesExistingFormats|JSONResult|EventsJSONLIsStructuredAndRedacted)' -count=1
- go test ./internal/cli ./internal/jobs ./internal/agent ./internal/bot ./internal/remote/workbench/runtime ./internal/serve ./internal/boot
- go test -race ./internal/cli ./internal/jobs
- merged with origin/main-v2: go test ./internal/cli ./internal/jobs
- merged with origin/main-v2: go test -race ./internal/cli
- merged with origin/main-v2: ./scripts/cache-guard.sh
- git diff --check

* fix(machine): fail closed on lifecycle repair errors

Problem:
- Session rotation could leave remote and bot controllers serving an unleased transcript after lease transfer failed.
- Machine event JSONL exposed provider-controlled tool identifiers and names.
- Failed interrupted-job metadata repair could publish an in-memory state that was not durable.

Root cause:
- Post-mutation lease errors and recovery write errors were treated as recoverable without restoring a consistent ownership or persistence boundary.

Fix:
- Retire rotated remote and bot sessions when the replacement lease cannot be acquired.
- Replace provider tool identities with stable per-run opaque aliases.
- Defer interrupted tombstones when metadata repair fails, emit a warning, and retry on the next bind.
- Add focused regression coverage for each failure mode.

Verification:
- go test ./internal/cli ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime
- go test -race ./internal/cli ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime
- go test ./internal/agent ./internal/control ./internal/serve ./internal/boot
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

* fix(machine): harden IDs and preserve active runtimes

Problem:
- Runtime profile and bot routing switches could close controllers that still owned pending prompts or background jobs.
- Machine session IDs used an unkeyed hash of guessable transcript branch names, allowing offline enumeration.

Root cause:
- Lifecycle guards only covered foreground turn state and did not serialize bot admission with runtime replacement.
- The public ID derivation had no installation-secret entropy.

Fix:
- Gate Remote close/profile operations and Bot routing rebuilds on running turns, pending prompts, and background jobs, with a second pre-swap check and an admission-serialized Bot switch.
- Initialize a private 32-byte per-install key under the Reasonix state directory with a cross-process lock and atomic 0600 write, derive 128-bit HMAC session IDs, and fail closed on corrupt identity state.
- Load the key before events-jsonl provider execution, document first-use initialization, and add focused lifecycle, concurrency, and privacy regressions.

Verification:
- go test ./internal/cli ./internal/remote/workbench/runtime ./internal/bot -count=1
- go test -race ./internal/remote/workbench/runtime ./internal/bot ./internal/cli ./internal/jobs -count=1
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- cd desktop && go test ./... -count=1
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* fix(machine): bind lifecycle repair to live ownership

Problem:
Startup subagent cleanup could interrupt work owned by another process, repair authorization survived lease handoff windows, and hook machine output labeled unusable entries active.

Root cause:
Subagent cleanup rewrote a shared directory without parent lease ownership, one registry mixed pending and active lease generations, and hook status did not reuse actual loader and matcher semantics.

Fix:
Acquire each parent transcript lease before stale subagent repair, publish only active lease generations and revoke them before unlock, and classify hook executability with shared event matcher semantics. Add deterministic cross-process, controlled handoff, invalid-hook, and compatibility regressions.

Verification:
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- env -u DEEPSEEK_API_KEY go test -race ./internal/agent ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime ./internal/cli ./internal/serve -count=1
- cd desktop && env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* fix(machine): verify projected runtime state

Problem:
- task JSON could report missing or failed artifacts as complete
- plugin context paths that only existed could appear executable
- session help described opaque machine IDs as branch IDs

Root cause:
- persisted metadata and filesystem readiness were projected separately
- hook status and diagnostics used existence-only checks
- localized usage text retained an earlier identifier name

Fix:
- fail closed unless a terminal job has a readable regular persisted artifact without an artifact error
- share plugin context usability checks across machine status and diagnostics
- correct machine session ID help in all supported locales and add regressions

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/jobs ./internal/bot ./internal/remote/workbench/runtime ./internal/cli ./internal/serve
- go test ./... (desktop)
- go vet ./...
- ./scripts/cache-guard.sh

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-24 09:12:24 +08:00
SivanCola 0f42b4b650 feat(agent): Planner and sub-agent trusted MCP via stable use_capability / Planner 与子 Agent 安装即信任 MCP (#6865)
* feat(agent): trust installed MCP in Planner and task sub-agents via stable use_capability

Problem:
The dual-model Planner and task/fleet children could not reliably query installed
MCP servers (e.g. databases) without readOnlyHint or dynamic mcp__ schemas, so
plans and delegated research were often ungrounded. Issue #6854.

Root cause:
Planner and strict/ordinary child registries either excluded MCP or required
readOnlyHint, and MCP tools entered provider-visible schemas so connect churn
broke prompt cache. Delivery alone had use_capability.

Fix:
- NewPlannerAgent with PlannerMCPExecution: authorized non-destructive MCP via
  use_capability without readOnlyHint; destructive left for Executor.
- Session-shared MCPCapabilityRuntime (Host/specs/connections) with per-agent
  use_capability frontends (isolated ledger/audit).
- Ordinary task/fleet children get full proxy (list/inspect/call); destructive
  follows normal writer permissions.
- Strict read-only children share proxy schema/reuse but keep authorized +
  readOnlyHint + non-destructive execution.
- Strip direct mcp__* from Planner and sub-agent registries; convert profile
  MCP allowlists to capability IDs; action=list for discovery.
- NonDestructiveMCPExecutionIntent re-check before tools/call; Desktop MCP
  tool card + docs.

Verification:
- go test ./internal/agent ./internal/boot ./internal/control ./internal/plugin ./internal/capability
- ./scripts/cache-guard.sh
- pnpm --dir desktop/frontend typecheck
- git diff --check

Fixes #6854

* fix(agent): close trusted MCP auth, connect gate, and review proxy gaps

Problem:
PR #6865 left three P1 boundaries incomplete: deferred MCP targets treated
!RequireLaunchApproval as authorized (bypassing Spec.Authorized), authorized
mcp_connect__* still entered the ordinary permission Gate, and review/read_only_skill
children never received the shared capability runtime or use_capability allowlist.

Root cause:
Connect helpers invented trust instead of using ServerAuthorized(); lifecycle
connect targets were excluded from the installed-MCP fast path; boot skill
runners still called registry builders without capRuntime and builtin review
AllowedTools omitted use_capability.

Fix:
- Deferred MCP targets and ensureServerTools use only Spec.ServerAuthorized().
- Authorized MCP tools and lifecycle connects share the explicit-deny-only gate.
- Wire capRuntime into read_only_skill and subagent skill runners; add
  use_capability to builtin review/security-review allowlists.
- Regression tests for zero-start unauthorized non-project MCP and trusted
  first connect under deny-all ordinary gates.

Verification:
- go test ./internal/agent ./internal/skill ./internal/plugin ./internal/boot

* fix(agent): stabilize restricted proxy prefix and isolate planner ledger

Problem:
Restricted use_capability proxies embedded dynamic capability IDs in their
Description, so MCP install/tool-list changes altered provider-visible tool
bytes (cache churn). Delivery dual-model still shared Executor use_capability
and ledger with the Planner. Restricted action=list leaked every configured
server. An empty if in subagent_registry_test triggered staticcheck SA4006.

Root cause:
Allowlist text was baked into tool descriptions; PlannerToolRegistry copied
parent use_capability before the clone branch; attachSubagentCapabilityProxy
returned early when a parent proxy was already present; list delegated to the
unfiltered frontend.

Fix:
- Keep restricted proxy description/schema identical to the base proxy; enforce
  allowlists only in host checks and filtered list results.
- Always strip and reinstall an isolated use_capability frontend for sub-agents
  and the Planner; give the Planner its own ledger and audit in boot.
- Filter list output to allowlisted servers only.
- Assert FilterReadOnlyRegistry keeps authorized RO MCP; add stability and
  isolation regression tests.

Verification:
- go test ./internal/agent ./internal/boot ./internal/skill
- git diff --check

* fix(agent): fail-closed restricted MCP list filtering

Problem:
Malformed allowed-tools entries such as "mcp-server:" produced a non-empty
capability allowlist but an empty server set, so filterCapabilityListResult
returned the unfiltered inventory. Unreadable list payloads also passed through.

Root cause:
Incomplete capability IDs were accepted literally, and empty-server / parse
errors used fail-open returns.

Fix:
- Accept only complete mcp-server:<name> and mcp-tool:<server>/<tool> IDs.
- Skip installing a restricted proxy when no servers resolve from the allowlist.
- Empty server sets and JSON parse/encode failures return an empty servers list.
- Regression tests for malformed IDs, empty servers, and bad list payloads.

Verification:
- go test ./internal/agent -run 'TestRestricted|TestMalformed|TestFilterCapability|TestPlannerTool|TestCustomProfile'

* fix(ui): show resolved MCP capability effects

Problem:
The stable use_capability proxy made Desktop and CLI present MCP calls only by the proxy surface. Writer MCP calls could be grouped as quiet research, and terminal users saw the implementation-level proxy name instead of the selected capability.

Root cause:
Reasonix resolved the real MCP target only inside execution. That target name, capability ID, and read-only classification were not carried through durable session history, frontend events, or Remote history projections.

Fix:
- Persist optional resolved target metadata while keeping the provider-visible tool call unchanged.
- Emit deterministic same-ID dispatch refreshes after MCP resolution and carry the same classification on tool results.
- Classify Desktop MCP cards by the resolved effect, fail closed for legacy history, and preserve the metadata across Local and Remote reloads.
- Render use_capability as MCP(capability_id) in the CLI TUI and headless output.
- Add concurrency, serialization, history, protocol, Desktop, and CLI regression coverage.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test
- go test -race ./internal/agent -run 'TestResolvedCapabilityDispatchRefreshesWriterClassification|TestResolvedCapabilityRefreshesParallelCallsInProviderOrder|TestUpdateToolCallResolutionPersistsAfterMidTurnSnapshot'
- ./scripts/cache-guard.sh
- go run ./cmd/remote-protocol-gen -check -root .
- git diff --check

* fix(agent): close trusted MCP proxy safety gaps

Problem:
Restricted sub-agent MCP allowlists could reveal sibling tool schemas, proxy-resolved writers could execute concurrently, and Balanced Executors could not directly call capability IDs discovered by their Planner.

Root cause:
Tool grants were widened to server IDs, batching trusted the proxy's read-only surface before resolving the real target, and dual-model Balanced installed use_capability only on the Planner registry.

Fix:
Keep tool grants exact and filter concrete tool inspection, serialize use_capability for every agent, and install isolated stable proxy frontends on both Balanced Planner and Executor while sharing the MCP runtime. Update bilingual contracts and add focused regressions.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/agent
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

* fix: enforce trusted MCP runtime boundaries

Problem:
Path-scoped sub-agents could resolve use_capability into unscoped MCP writers, and the stable MCP proxy kept boot-time specs after add, update, disable, or removal.

Root cause:
use_capability was classified as statically read-only before dynamic resolution, while MCPCapabilityRuntime and capability routing copied immutable boot snapshots that were not synchronized with controller lifecycle changes.

Fix:
Keep the provider-visible proxy contract stable while restricting explicit write_paths tasks to proven read-only MCP targets. Make each controller runtime an authoritative, race-safe MCP inventory, synchronize CLI/Desktop lifecycle changes and shared-host tabs, clear stale live tools, and route from live runtime snapshots.

Verification:
go test ./internal/agent ./internal/boot ./internal/control ./internal/plugin
go test -race ./internal/agent ./internal/control
env -u DEEPSEEK_API_KEY go test ./...
cd desktop && go test ./...
go vet ./...
./scripts/cache-guard.sh
git diff --check

* fix(agent): bind trusted MCP dispatch to live runtime identity

Problem:
Planner and stable capability frontends could reuse a same-name MCP client from another controller or stale configuration. Plan mode also blocked authorized opaque non-destructive MCP, lifecycle snapshots could mix generations, and legacy Desktop history lost built-in reader classification.

Root cause:
Shared Host reuse checked only the server name, resolved targets were not bound to the controller's current enabled/authorized spec, lifecycle mutations were not linearized with tools/call, and routing read configuration and live tools in separate snapshots. Documentation also described ordinary sub-agent MCP as using normal writer permission prompts even though installation is the trust boundary.

Fix:
- Compare complete host-local MCP runtime identity and require exact identity for Host/tool reuse.
- Bind resolved MCP targets to the live runtime and re-check enablement, authorization, identity, and destructive safety before dispatch.
- Linearize connect/call against update, disable, and removal; return atomic catalog/live-tool snapshots.
- Preserve Planner Plan-mode access to authorized non-destructive MCP without readOnlyHint and safely complete connected server-directory calls.
- Restore legacy Desktop reader classification and align bilingual contracts with explicit-deny-only installed MCP trust and direct-Executor cache limits.
- Add identity, shared-Host, real opaque Planner, revocation, concurrency, and frontend regressions.

Verification:
- go test ./internal/agent ./internal/plugin ./internal/control ./internal/boot -count=1
- go test ./internal/agent -run 'TestPlannerPlanModeExecutesAuthorizedOpaqueMCPThroughRuntime|TestRuntimeDisableLinearizesWithInFlightMCPDispatch|TestMCPCapabilityRuntimeConcurrentUpdatesAndSnapshots' -count=1
- go test ./internal/plugin -run TestMCPRuntimeSpecMatchesExactHostIdentity -count=1
- ./scripts/cache-guard.sh
- pnpm --dir desktop/frontend exec tsx src/__tests__/tool-subject.test.ts
- pnpm --dir desktop/frontend typecheck
- git diff --check

* fix(agent): remove obsolete MCP runtime wrapper

Problem:
The latest PR head failed golangci-lint because ensureServerTools remained as an unused private wrapper after every dispatch path moved to the identity-bound ensureServerToolsForSpec helper.

Root cause:
The runtime-identity refactor replaced all callers but left the compatibility wrapper behind. Go compilation and vet allow unused methods, while CI's unused analyzer correctly rejected it.

Fix:
Delete the dead wrapper so all connect paths continue to require the already-authorized current runtime spec explicitly.

Verification:
- go test ./internal/agent -count=1
- go vet ./...
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
- git diff --check

* fix(agent): preserve MCP images and route IDs

Problem:
The first use_capability call to an unconnected MCP server dropped structured image results, and Planner transient routing rewrote concrete MCP capability IDs to source targets that use_capability cannot call.

Root cause:
onDemandMCPTool exposed only the text Execute interface, so Agent dispatch could not select ExecuteWithImages. RenderTransientBlock applied legacy source rewriting before accounting for Planner MCP proxy routing.

Fix:
Give the deferred MCP target a shared image-aware execution path that retains the runtime authorization lock through tools/call. Keep concrete MCP IDs for capability-proxy routes while preserving ordinary source routing for non-MCP capabilities. Add full Agent-path image coverage and focused route rendering regressions.

Verification:
- go test ./internal/agent ./internal/capability ./internal/control ./internal/boot
- env -u DEEPSEEK_API_KEY go test ./...
- go test -race ./internal/agent -count=1
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check
2026-07-24 08:21:22 +08:00
SivanCola 8eb9707a3f Fix rich composer caret stability / 修复富文本输入框光标跳尾 (#6872)
* fix(desktop): stabilize rich composer caret after invocation tags

Problem:
After selecting a skill or plugin invocation, mid-text edits in the desktop
rich composer often jumped the caret to the end of the draft on the first
keystroke, especially on Windows WebView2.

Root cause:
DOM→model counted user text that landed inside the NBSP caret anchor, but
model→DOM selection restore skipped the entire anchor. When selection was
temporarily unavailable, the code also faked the caret at text.length.

Fix:
Unify DOM walk rules for model reads, selection reads, and selection restore
so invocation chips stay zero-length while anchor body text remains editable.
Restore both selection ends, keep afterInvocationId behavior, and recover the
insertion point from a beforeinput snapshot when the browser drops selection.
Add focused rich-composer selection regressions and an Unreleased note.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/rich-composer-selection.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend build

* fix(desktop): recover rich caret after IME blackout and data-null repeats

Problem:
Two caret-recovery edges remained after the initial #6868 fix. IME
compositionend could restore the pre-composition offset when WebView2 dropped
selection, and data-null inserts of a repeated character (e.g. "aaa" → "aaaa"
at offset 1) recovered to the end via maximal prefix/suffix matching.

Root cause:
compositionstart never froze an edit snapshot while composing beforeinput was
skipped, so compositionend had no mid-edit baseline. recoverSelectionAfterEdit
also trusted a longest common prefix that claims the whole string when the
inserted run matches surrounding characters.

Fix:
Snapshot model text and caret on compositionstart; keep that baseline through
composition. Reconstruct post-edit carets with a selection-anchored head/middle/
tail splice before falling back to prefix/suffix, and prefer that recovery when
a live selection is parked at the end after a mid-text edit. Cover both edges
in rich-composer-selection tests.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/rich-composer-selection.test.tsx
- pnpm --dir desktop/frontend exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm --dir desktop/frontend typecheck

* fix(desktop): preserve late WebView2 IME commits

Problem:
Windows WebView2 can dispatch compositionend before the committed IME DOM and final non-composing input are visible. Synchronizing the controlled model at compositionend publishes the stale pre-composition text and erases the selected candidate.

Root cause:
The composer assumed every Chromium host exposed the committed DOM by compositionend. Deferring every compositionend avoided the WebView2 blackout but regressed browsers and submit paths that require the committed text synchronously.

Fix:
Keep the pre-composition snapshot, synchronize immediately when compositionend already exposes changed DOM, and defer only while the DOM still matches the snapshot. Let the following non-composing input own late WebView2 commits, with a next-frame fallback and cleanup cancellation. Cover both event orders explicitly.

Verification:
- rich-composer-selection: 80 passed
- composer-goal-toggle: 171 passed
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend build
- git diff --check
2026-07-24 02:20:21 +08:00
217f768ce1 Fix provider model state without changing session defaults / 修复 Provider 模型状态并保持会话默认边界 (#6881)
* fix(desktop): sync provider model state

* fix(desktop): preserve provider state boundaries

Problem: The source PR made per-session model switches rewrite the global default model and silently disabled existing provider balance endpoints.

Root cause: Session-local model state and user-global defaults were treated as one setting, while the stale-balance race fix was coupled to an unrelated provider capability restriction.

Fix: Keep hot model switches tab-local, retain existing balance_url behavior, and add regression coverage proving the global default and sibling tabs remain unchanged.

Verification: go test ./... (desktop); pnpm test; pnpm build; focused config/frontend tests; ./scripts/cache-guard.sh; git diff --check.

Co-authored-by: par73e <[email protected]>

* fix(desktop): serialize provider model switches

Problem:
Rapid model selections could let an older completion repopulate stale wallet state, while no-op or failed switches discarded the last known balance.

Root cause:
Balance request generations did not order SetModelForTab calls or distinguish stale model-switch completions. The current model also remained actionable without accounting for an in-flight switch.

Fix:
Serialize model switches per tab, track switch ownership and successful backend transitions, restore cached balance only when safe, ignore failed balance confirmations, make settled current-model selection a no-op, and invalidate provider state when tabs are discarded.

Verification:
- pnpm test
- pnpm build
- go test ./...
- go test -race . -run TestSetModelForTab -count=1
- ./scripts/cache-guard.sh

* fix(desktop): reconcile provider state across races

Problem:
Queued model fallback, cross-tab pending picks, and failed tab closes could leave provider UI state stale or trigger unnecessary switches.

Root cause:
Failure reconciliation omitted model metadata, pending pick ownership was component-global, and close invalidation ran before backend success.

Fix:
Refresh metadata after an older queued success, scope pending picks per tab, and invalidate provider generations only after a successful close. Add regression coverage for all three overlaps.

Verification:
pnpm test
pnpm build
pnpm exec tsc --noEmit -p tsconfig.test.json
go test -race . -run TestSetModelForTab -count=1

* Fix failed model switch reconciliation

Problem:
Rapid queued model switches could lose the pre-switch balance when every rebuild failed, and a failed selection could remain optimistically marked as current in the model picker.

Root cause:
Each queued attempt captured the already-cleared balance independently, while the picker did not receive or sequence the controller switch outcome.

Fix:
Keep one fallback balance for each per-tab switch queue, return an explicit switch result through App and Composer, and roll back plus reload the catalog only for the latest failed pick. Invalidate pre-click catalog requests so stale results cannot overwrite a retry.

Verification:
- pnpm test:all
- pnpm build
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- ./scripts/cache-guard.sh
- git diff --check

---------

Co-authored-by: par73e <[email protected]>
Co-authored-by: par73e <[email protected]>
2026-07-23 23:46:55 +08:00
SivanCola 035f92897f fix(desktop): support authorized Debian package updates / 支持 Debian 安装包应用内授权更新 (#6866)
* fix(desktop): support authorized Debian package updates

Problem:
Ubuntu users who installed the desktop .deb could not update in app because the updater could not replace root-owned system files.

Root cause:
Linux installs were treated uniformly as writable portable layouts, and the release manifest exposed only the tarball updater artifact.

Fix:
Detect dpkg-owned installs, publish signed native .deb metadata, and install upgrades through a packaged Polkit helper that re-verifies ownership, signatures, package identity, architecture, and version before delegating to apt. Preserve the portable tarball path and add user-facing progress, metrics, tests, and recovery documentation.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:updater
- pnpm --dir workers/crash-report typecheck
- pnpm --dir workers/crash-report test
- Linux amd64 helper test binary cross-compilation
- git diff --check

* fix(desktop): recover from updater mode changes

Problem:
The desktop CI failed on two unused updater helpers, and the UI could remain stuck in downloading or authorizing when install mode or manifest availability changed between updater steps.

Root cause:
The backend opened the manual download page and returned nil after reclassification, while the frontend treated nil as an in-flight operation with no terminal state. Two obsolete helper functions also remained after the refactor.

Fix:
Return and emit a typed manual-update error before opening the download page, preserve the manual fallback action in the shared updater state, add authorization/install/cancellation/reclassification coverage, and remove the unused functions.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test:updater
- pnpm --dir desktop/frontend build
- pnpm --dir workers/crash-report typecheck
- pnpm --dir workers/crash-report test
- Linux amd64 update-helper test binary and production build
- git diff --check

* Fix Linux package smoke checks under pipefail

Problem:
Linux .deb contract smoke checks could fail on valid packages when the producer emitted more data after grep found its match.

Root cause:
The build script combined set -o pipefail with grep -q. Early grep exit could close the pipe and make dpkg-deb terminate with SIGPIPE, turning a successful contract match into a failed pipeline.

Fix:
Consume the complete dpkg-deb output with non-quiet grep redirected to /dev/null, and add a packaging guard that rejects the unsafe early-exit patterns.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -run TestDesktopPackagesPreserveNativePlatformLaunchers -count=1 .
- pnpm --dir desktop/frontend test:updater
- bash -n scripts/desktop-build.sh
- git diff --check
2026-07-23 23:22:44 +08:00
SivanCola 925cb07bae Bound Auto recovery by Episode and exit Plan without executing / 按 Episode 限制 Auto 恢复并支持退出计划 (#6871)
* fix(auto): scope recovery failures to exact operations

Problem:
Auto Guard treated consecutive execution failures and reviewer rejections as task-wide state. After repeated test failures, unrelated workspace writes could remain blocked, and revising the proposal or starting a new ordinary turn did not reliably clear the latch.

Root cause:
Failure and reviewer counters were keyed only by the root task. Recovery observations carried no turn scope, revise kept the active failure, and the display surfaced only a generic Auto Guard error.

Fix:
Scope failure budgets to the exact operation and recovery scope, rotate episodes for different operations and ordinary turns, clear them on revise or when leaving Auto, retain stable Goal scopes across recovery persistence, scope reviewer rejection budgets to each proposal, and surface actionable blocker text. Update the English and Chinese approval-mode documentation and add compatibility coverage.

Verification:
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go test -race ./internal/recovery ./internal/control -count=1
- cd desktop && go test . -run 'Recovery|AutoGuard' -count=1
- ./scripts/cache-guard.sh
- go vet ./internal/recovery ./internal/agent ./internal/control

* fix(plan): add explicit non-executing exit

Problem:
Plan approval offered execution or revision, but no direct way to leave Plan without running the proposal. Rejecting the prompt kept Plan active, so desktop and CLI users could become stuck in the planning workflow.

Root cause:
The desktop already had an exit callback but did not expose it as a decision. The CLI represented Plan as a two-choice boolean approval and only cleared Plan after approval.

Fix:
Add a third exit-without-executing action on desktop and CLI, switch both UI and controller state out of Plan before rejecting that action, preserve revise and n/Esc keep-planning behavior, localize all supported languages, update docs, and add click, keyboard, duplicate-submit, banner, and state-sync coverage.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm typecheck
- pnpm check:css
- pnpm build
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./internal/cli ./internal/i18n
- git diff --check

* docs(plan): clarify cross-client shortcuts

Problem:
The approval guide described Desktop click behavior and Esc handling as if they applied identically to the CLI, leaving the new CLI Plan decision ambiguous.

Root cause:
The Plan action documentation was written before the CLI received its explicit three-action workflow.

Fix:
Document Desktop and CLI activation separately, state that CLI n/Esc keeps planning during a pending Plan confirmation, and scope the generic Esc stop behavior accordingly in English and Chinese.

Verification:
- git diff --check

* test(plan): pin localized three-action contract

Problem:
The CLI Plan workflow depends on three numbered actions in every supported locale, but tests only checked the English labels and non-empty catalog fields.

Root cause:
Localization completeness did not assert the semantic action count or the execute, revise, and exit-without-executing labels.

Fix:
Add a catalog-level regression test that requires exactly three numbered Plan actions and the expected semantic labels in English, Simplified Chinese, and Traditional Chinese.

Verification:
- go test ./internal/i18n -count=1
- git diff --check

* fix(auto): bound recovery by execution episode

Problem:
Auto recovery could still feel like a sticky lock: repeated Maven/test failures
blocked progress, different parameter retries could avoid the same-operation
cap, and restart or mode hydration risked re-arming temporary failure state.
Users needed a low-friction path where this turn auto-recovers with a hard
ceiling, then the next turn recovers automatically.

Root cause:
Failure and reviewer budgets mixed TaskScope with temporary execution state,
kept a single active failure rather than Episode-scoped counters, re-armed
locks from sidecar snapshots, and lacked a generation barrier for late tool
results after mode switches. There was no turn-level stop with a summarize-only
finalization path or a wire-compatible recovery_paused outcome.

Fix:
- Separate host-owned EpisodeID (failure/reviewer/stop budgets) from TaskScopeID
  (Goal and explicit task grants).
- Fixed budgets with no settings: 3 identical operation failures, 6 Episode
  execution failures since last real progress, 3 cumulative reviewer rejects,
  3 re-submissions of stopped operations.
- Successful mutation/verification clears no-progress budgets; diagnostic reads
  do not. Provider/cancel/user-reject/pre-exec blocks stay out of the budget.
- Gate generation invalidates stale observations; real mode changes rotate
  Episode and dismiss recovery waiters without approving mutations. Same-value
  mode replays are no-ops.
- Episode stop blocks remaining tools, offers one finalization round, then
  returns RecoveryPauseError with turn_done.outcome=recovery_paused (info UI,
  not send-failed).
- Persistence writes last_failure evidence only; restore never re-arms locks.
- Content-free metrics for stops, mode resets, and stale observations.

Verification:
- go test ./internal/recovery ./internal/agent ./internal/control ./internal/eventwire ./internal/cli -count=1
- go test -race ./internal/recovery ./internal/agent ./internal/control -count=1
- pnpm exec tsx desktop/frontend/src/__tests__/send-failed.test.ts
- ./scripts/cache-guard.sh
- go vet ./internal/recovery ./internal/agent ./internal/control ./internal/event
- git diff --check

* style(auto): format episode recovery changes

Problem:
The Episode recovery commit left three root-module Go files outside gofmt canonical formatting, causing the macOS and Ubuntu CI jobs to stop before vet, build, and tests.

Root cause:
The final Episode changes were committed without applying gofmt to all touched Go files.

Fix:
Run gofmt on the three files reported by the same command used in CI. The resulting diff is whitespace-only.

Verification:
- gofmt -l . excluding the independent desktop module returns no files
- git diff -w --exit-code
- git diff --check

* fix(auto): close Episode budget bypasses and lint gaps

Problem:
Episode hard limits could still be bypassed three ways: a successful
recovery summary returned nil so Goal auto-continue opened another Run
with a fresh finalization round; total failure and reviewer budgets were
per TaskID so each new sub-agent got a full ceiling; and a reviewer
Continue cleared cumulative rejects before any real mutation progress.
golangci-lint also failed on empty branches and unused helpers.

Root cause:
Finalization success treated the pause as a normal turn end; Episode
totals lived on taskRuntime; reviewRejects reset on Continue; dead
helpers and empty branches remained from the Episode refactor.

Fix:
- Return RecoveryPauseError after a successful summarize-only finalization
  so turn_done reports recovery_paused and Goal auto-continue stops.
- Keep exact-operation counters task-scoped; move Episode totals,
  reviewer rejects, stopped-op retries, hard stop, and finalization to a
  Gate-level episode budget shared by root and all sub-agents.
- Clear reviewer rejects only on real mutation/verification success, a
  new Episode, or revise — not on reviewer Continue or human continue.
- Remove unused helpers/empty branches and add regressions for shared
  sub-agent budgets and reject→continue→reject cumulative counting.
- Block Goal on RecoveryPauseError like FinalReadinessError.

Verification:
- go test ./internal/recovery ./internal/agent ./internal/control -count=1
- go test -race ./internal/recovery ./internal/agent ./internal/control -count=1
- go vet ./internal/recovery ./internal/agent ./internal/control
- gofmt clean on touched packages
- git diff --check

* fix(auto): keep Goal running after recovery pause

Problem:
RecoveryPauseError set the Goal to blocked. The next ordinary user message
opened a fresh Episode but lost the active Goal prompt and delivery scope, so
replying continue did not actually resume Goal work without an explicit resume.

Root cause:
goalShouldBlockOnError treated RecoveryPauseError like FinalReadinessError and
called stopGoal(GoalStatusBlocked). Only running Goals reinject model context
and delivery scope.

Fix:
Keep the Goal running after RecoveryPauseError so auto-continue stops but the
next real user turn reuses the same Goal and delivery scope. Reserve
GoalStatusBlocked for FinalReadinessError. Add a regression covering pause then
ordinary continue.

Verification:
- go test ./internal/control -run 'RecoveryPause|GoalReadiness' -count=1
- go test ./internal/control -count=1

* fix(recovery): present paused turns consistently

Problem:
Recovery pauses were informational in the Desktop React chat, but the Desktop host, bot bridge, Serve WebUI, TUI, metrics, and headless CLI still treated them as failures.

Root cause:
Those consumers classified turn completion from a non-nil error alone and ignored the recovery_paused outcome. The headless CLI also had separate result and exit-code classification paths.

Fix:
Honor recovery_paused across every client surface, keep ordinary errors unchanged, and centralize headless completion classification so notifications, JSON output, user messaging, and exit codes agree.

Verification:
go test ./internal/cli ./internal/serve ./internal/control ./internal/eventwire ./internal/agent ./internal/recovery -count=1
go test -race ./internal/cli ./internal/control -count=1
cd desktop && go test ./... -count=1
cd desktop && go vet ./...
pnpm exec tsx src/__tests__/send-failed.test.ts
./scripts/cache-guard.sh
go vet ./internal/cli ./internal/serve ./internal/control ./internal/event ./internal/eventwire
git diff --check
2026-07-23 20:15:25 +08:00
JesonChouandSivanCola 04c0d88f4d feat(desktop): expand syntax highlighting for more languages / 拓展更多种语言的语法高亮支持 (#6861)
* feat(desktop): expand highlight.js language set to 52 languages

Register 26 additional languages (from 26 to 52) covering common
ecosystems: lua, dart, groovy, objectivec, r, perl, fsharp, haskell,
elixir, clojure, erlang, julia, scss, less, latex, vim, nginx, apache,
protobuf, graphql, cmake, gradle, properties, coffeescript, matlab,
and vbnet.

Map XAML (.xaml) to XML highlighting since highlight.js has no native
XAML lexer and XAML is an XML dialect.

Extend EXT, ALIASES (lang.ts) and byExt (selectedTextContext.ts) tables
with corresponding file-extension and fence-tag aliases so all three
code-preview paths — Markdown fenced blocks, tool diffs, and the
workspace file viewer — resolve the correct language.

Closes #6825 (desktop portion).

* fix(desktop): correct jsx/tsx languageFor mappings to registered hljs names

languageFor() in selectedTextContext.ts mapped .jsx and .tsx to "jsx" and
"tsx", but highlight.js does not register languages under those names --
the actual registered names are "javascript" and "typescript". While the
immediate consumer (formatSelectionReference) works correctly because the
downstream highlightToHtml() passes through resolveLang() which handles
these aliases, the mapping is technically wrong and would break for any
future consumer that uses the languageFor return value directly without
alias resolution.

Fix: map jsx to "javascript" and tsx to "typescript" to match the
highlight.js registration names, making the function self-consistent.

* fix(desktop): unify syntax highlight language mapping

Problem:
- File previews and tool diffs could disagree for special filenames such as Dockerfile, Makefile, CMakeLists.txt, and .htaccess.
- Plan-revision fences replaced semantic tags such as TSX, HTML, TOML, and XAML with fallback lexer names.

Root cause:
- Path-to-language behavior was duplicated across independent tables, and extension-only matching could not recognize extensionless build and server configuration files.

Fix:
- Centralize semantic path mapping for both slash styles, extensions, and special basenames.
- Resolve semantic tags to highlight.js lexers only at the rendering boundary.
- Add regression coverage for mapped lexers, special filenames, UI consistency, and provider-visible fence tags.

Verification:
- pnpm test:typecheck
- pnpm test
- pnpm build
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>

* test(desktop): cover remaining special language paths

Problem:
- The shared path resolver supported httpd.conf and .makefile suffixes, but the focused path matrix did not exercise those branches directly.

Root cause:
- The initial regression matrix covered the lexer set and primary special basenames without one concrete path for each remaining matcher.

Fix:
- Add end-to-end path, tool-diff, file-preview, and lexer assertions for httpd.conf and module.makefile.

Verification:
- pnpm exec tsx src/__tests__/language-highlighting.test.ts
- pnpm test:typecheck
- git diff --check

* fix(desktop): cap chat syntax highlighting

Problem:
- Ordinary chat and tool code blocks always ran highlight.js, even when a block exceeded the workspace preview's 512 KiB or 20,000-line safety budget.

Root cause:
- The highlighting budget lived inside the line-numbered workspace viewer instead of the shared highlighting layer.

Fix:
- Move the byte and line limits into the shared highlighter.
- Measure ordinary blocks using UTF-8 bytes without allocating a duplicate buffer.
- Fall back to escaped plain text above either limit while preserving content and copy behavior.
- Reuse the same helper from workspace and ordinary code viewers.

Verification:
- pnpm exec tsx src/__tests__/line-number-code.test.tsx
- pnpm exec tsx src/__tests__/language-highlighting.test.ts
- pnpm test:typecheck
- pnpm test
- pnpm build
- ./scripts/cache-guard.sh
- git diff --check

Co-authored-by: SivanCola <[email protected]>

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-23 18:56:50 +08:00
c5940f6319 fix(desktop): Preserve transcript selection during plan revision / 修复计划修订焦点抢占 (#6466)
* fix: plan mode focus stealing - stabilize onRevisionActiveChange and avoid unnecessary re-renders

* fix(desktop): preserve transcript selection during plan revision

Problem:
The open plan-revision textarea reclaimed focus whenever its parent rerendered, clearing transcript text selections before users could copy them.

Root cause:
The focus effect depended on a callback prop whose identity could change during periodic App refreshes, so every callback change called focus again. The original patch also relied on suppressing tab metadata rerenders instead of making the approval owner resilient.

Fix:
Keep the App callback stable, focus the revision textarea only when revisionOpen changes, track the latest callback for close and unmount cleanup, remove the unrelated JSON.stringify polling dedupe, and add a regression that churns the callback while a transcript selection is active.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm typecheck
- ./scripts/cache-guard.sh
- Browser smoke test at http://127.0.0.1:5173/

* test(desktop): document plan revision rerender regression

Problem:
The focus regression test simulated callback identity churn without documenting why that is the relevant production trigger.

Root cause:
Periodic tab metadata refreshes rerender App, which historically recreated the revision-active callback and refocused the textarea.

Fix:
Document the parent-rerender trigger next to the regression step.

Verification:
- pnpm exec tsx src/__tests__/approval-modal-file-reference.test.tsx
- git diff --check

---------

Co-authored-by: SauronSkywalker <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-23 18:12:09 +08:00
mchenziyiandSivanCola d385432a08 fix: 支持 Review 结果作为 complete_step 证据 (#6762)
* fix: add review evidence for complete_step

* fix: accept dedicated review tool receipts

* fix(evidence): require fresh review receipts

Problem:
complete_step accepted any successful review/task receipt in the turn, including a review that predated a later mutation or a background task that had only started.

Root cause:
The review evidence matcher scanned the whole ledger without applying mutation ordering, changed-path coverage, or the structured review_report contract.

Fix:
Require review evidence to be fresh for the latest mutation and cover its changed result. Accept collected structured review reports, reject background starts, and define review evidence as activity completion while leaving blocking verdict enforcement to the delivery gate.

Verification:
- go test ./internal/evidence ./internal/tool/builtin -count=1
- ./scripts/cache-guard.sh
- go vet ./...
- go test ./internal/plugin -run TestLazyCacheMissAsyncSpawn -count=1
- env -u DEEPSEEK_API_KEY go test ./internal/provider/openai -count=1

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-23 17:49:49 +08:00
mj-leeand9710willy 4499687209 fix(site): correct docs sidebar active-section highlight (#6222)
The scrollspy built its list of sections from the sidebar links, which are
grouped by topic rather than page order, then picked the last section
scrolled past as active. That assumes the list is in document order, so the
highlight settled on the wrong item. Sort the sections by document position.

Clicking a link also swept the highlight through every section it passed,
because the spy runs on each frame of the smooth scroll. Pin the highlight to
the clicked section and release it on scrollend, falling back to the plain
jump where scrollend isn't supported.

Set aria-current on the active link so assistive tech can announce it.

Co-authored-by: 9710willy <[email protected]>
2026-07-23 14:59:22 +08:00
SivanCola 020bf6ebf7 fix(release): carry reviewed notes into recoveries (#6843)
Problem: Stable recovery publishers failed when an immutable candidate predated its reviewed release-note entry. A partially successful run also had no safe way to retry only failed channels.

Root cause: CLI and Desktop rendered notes from the candidate checkout, while preflight validated notes from the protected control plane. The orchestrator always invoked every publisher during recovery.

Fix: Upload the preflight-rendered notes and consume that exact artifact in orchestrated CLI/Desktop publishers. Add opt-out channel switches for manual recovery while retaining public postflight verification for skipped channels.

Verification: bash scripts/release-workflows.test.sh; node scripts/release-notes.mjs render --version v1.17.19 --output /tmp/reasonix-release-notes-v1.17.19.md; git diff --check.
2026-07-22 15:53:46 +00:00
SivanCola 3bedc054a3 docs(release): prepare v1.17.19 notes (#6841)
Add reviewed bilingual product notes for the stable recovery workflow after the initial tag preflight found the catalog entry missing.
2026-07-22 15:32:14 +00:00
ikelvingo 5a5f65e2c2 agent/save: fix Windows crash when model name contains ':'
Fixes #6683

NewSessionPath() only sanitized '/' and '\' from model names, missing ':'
and other Windows-illegal filename characters. On Windows, ':' is the
NTFS alternate data stream (ADS) marker, causing:

- System freeze / crash when file system scanners index the session file
- CLI permanently stuck: lease.lock path with ':' triggers 'session is
  in use' error that can never clear
- All sidecar files (.ckpt, .lock, .lease.*, .events.jsonl) corrupted

Add all Windows-illegal chars to the replacer: :<>"|?*
2026-07-19 18:48:44 +08:00
883 changed files with 121376 additions and 14966 deletions

No files matched your search

+11
View File
@@ -1,3 +1,14 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
# Keep the provider-side signing policy and its GitHub control plane under the
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
+9
View File
@@ -2,6 +2,15 @@
-
## Issues
<!--
If this resolves a report, put `Fixes #123` on its own line — GitHub only
auto-closes from a bare line, so `- Fixes #123` in a list does nothing and the
report stays open. If it only relates to one, use `Refs #123` instead: the
release workflow then asks that reporter to verify once the fix ships.
-->
## Verification
-
+12
View File
@@ -4,6 +4,18 @@ on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
# The guard only cares about the prompt/tool surfaces listed in
# scripts/check-cache-impact.sh; PRs confined to clearly unrelated paths
# can never trip it, so skip the run entirely. Keep the ignore list
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
permissions:
contents: read
+153 -10
View File
@@ -14,22 +14,78 @@ concurrency:
cancel-in-progress: true
jobs:
# Cheap path gate for pull requests. PRs confined to docs/site/release-notes
# (or top-level Markdown) skip the heavy Go jobs, and PRs that cannot affect
# the desktop module skip the desktop jobs. Job-level `if` reports skipped,
# which satisfies the required status checks (lint, race, test) — a
# workflow-level paths-ignore would leave required checks pending and block
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
# `always()`, a failed `changes` job (or a missing output) makes them run
# the full matrix instead of silently passing required checks as skipped.
# Pushes to main-v2 always run everything.
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
desktop: ${{ steps.filter.outputs.desktop }}
site: ${{ steps.filter.outputs.site }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; site=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
elif [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
base="${{ github.event.before }}"
fi
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; site=false
# Root-module CI: desktop/ is a separate module, so only the
# clearly unrelated paths below can skip it.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
# desktop/ imports the root kernel via `replace reasonix => ../`,
# so only this clearly-unrelated set is safe to skip.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
if echo "$files" | grep -q '^site/'; then site=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; } >> "$GITHUB_OUTPUT"
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
# those required checks would stay "Expected" and block merging. The job
# therefore always runs and the steps do the gating: when the changes
# detector reports the diff is unrelated, every step skips and each leg
# reports success in seconds. `always()` also keeps the legs alive when
# the changes job itself fails (fail-open: an empty output != 'false').
test:
needs: changes
if: always()
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
steps:
- uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
- uses: actions/setup-go@v6
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
if: runner.os == 'Linux'
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
@@ -45,7 +101,7 @@ jobs:
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
# file. gofmt output is OS-independent, so the Unix legs already cover it.
- name: gofmt
if: runner.os != 'Windows'
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
run: |
# Root module only — desktop/ is a separate module with its own tooling.
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
@@ -56,13 +112,15 @@ jobs:
fi
- name: vet
if: env.RUN_STEPS == 'true'
run: go vet ./...
- name: build
if: env.RUN_STEPS == 'true'
run: go build ./...
- name: test
if: runner.os != 'Windows'
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -70,8 +128,19 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, plus cmd/. The full ./... sweep stays on
# pushes to main-v2; the Unix legs always run the full suite.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 10
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
@@ -84,6 +153,8 @@ jobs:
run: go test -p 4 -timeout=3m ./...
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -108,13 +179,24 @@ jobs:
# The matrix never runs -race (it needs cgo); the project's concurrency
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
# ship without race coverage.
- name: test -race
# ship without race coverage. Pull requests sweep only the
# concurrency-heavy packages so this required check stays fast; pushes
# to main-v2 keep the full ./... sweep as the safety net.
- name: test -race (concurrency packages)
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/...
- name: test -race (full)
if: github.event_name != 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
desktop:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: ubuntu-22.04
defaults:
run:
@@ -174,6 +256,9 @@ jobs:
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: Test integrated terminal frontend
run: pnpm --dir frontend test:terminal
- name: vet
run: go vet ./...
@@ -190,11 +275,34 @@ jobs:
- name: test
run: go test ./...
# desktop/ is a separate module, so the root macOS matrix above does not
# compile or exercise the native PTY implementation.
desktop-macos:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: macos-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- name: Test integrated terminal and PTY lifecycle
run: go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
# Desktop project-root matching is case-insensitive only on Windows
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
# regression tests for that contract are named *OnWindows and can never
# run on the ubuntu leg above.
desktop-windows:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: windows-latest
defaults:
run:
@@ -237,14 +345,21 @@ jobs:
timeout-minutes: 15
run: go test ./...
# Installer packaging is packaging validation, not a code gate: run it
# on pushes to main-v2 (the release pipeline builds installers again
# anyway), but let pull requests stop after the test step.
- name: Install NSIS
if: github.event_name != 'pull_request'
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
- name: Build Windows installer and portable archive
if: github.event_name != 'pull_request'
timeout-minutes: 20
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
lint:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -260,21 +375,45 @@ jobs:
version: v2.12.2
args: --timeout=5m
# Report-only complexity monitor for internal/agent. Does not duplicate the
# main lint/test/race gates; funlen+cyclop run with issues-exit-code=0 so
# the baseline is visible as a CI summary/artifact without blocking merges.
- name: agent complexity report
if: always()
run: |
# Reuse the golangci-lint binary installed by the previous step.
bash scripts/agent-complexity-report.sh
- name: upload agent complexity report
if: always()
uses: actions/upload-artifact@v7
with:
name: agent-complexity-report
path: agent-complexity-report.txt
if-no-files-found: ignore
retention-days: 14
- name: release workflow contracts
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-stable-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
.github/workflows/release-desktop.yml \
.github/workflows/release-verify-issues.yml
bash scripts/release-workflows.test.sh
node scripts/check-single-release-public-contract.mjs
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
site:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.site != 'false')
runs-on: ubuntu-latest
defaults:
run:
@@ -290,6 +429,8 @@ jobs:
run: npm test
govulncheck:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
@@ -307,6 +448,8 @@ jobs:
run: govulncheck ./...
coverage:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+10
View File
@@ -1,10 +1,20 @@
name: CodeQL
on:
# Docs/release-notes/root-Markdown changes carry no analyzable code; the
# weekly cron keeps the full scan cadence regardless.
push:
branches: ["main-v2"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
pull_request:
branches: ["main-v2"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
schedule:
- cron: "27 3 * * 1"
+34 -16
View File
@@ -1,16 +1,12 @@
name: Prepare release notes
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Release version without a tag prefix (for example 1.18.0)"
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
from_tag:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
permissions:
contents: write
@@ -29,16 +25,23 @@ jobs:
with:
node-version: "22"
- name: Prepare the release-notes branch
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
branch="release-notes/v${VERSION#v}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" main-v2
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
@@ -52,15 +55,17 @@ jobs:
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
FROM_TAG: ${{ inputs.from_tag }}
run: |
set -euo pipefail
args=(--version "$VERSION")
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
node scripts/generate-release-notes.mjs "${args[@]}"
node scripts/generate-release-notes.mjs --version "$VERSION"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
{
echo "## Review draft for v${VERSION#v}"
echo
cat /tmp/release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Open or update the review PR
env:
@@ -71,13 +76,26 @@ jobs:
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Generate a bilingual, product-focused draft from merged pull request metadata. The reviewed catalog will drive the website, desktop app, and GitHub releases."
-m "Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
File diff suppressed because it is too large. Load diff
+46 -56
View File
@@ -1,34 +1,22 @@
name: Release npm
# npm line. Stable releases are called by release-stable.yml after its single
# GitHub environment approval. Direct tag pushes are retained only for
# prereleases so npm `next` can still ship independently without touching the
# stable Homebrew cask. Bump a prerelease with:
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# A manual workflow_dispatch can publish either an opt-in canary from the current
# ref or recover an approved stable version from an existing tag. Canary publishes
# to the `canary` dist-tag (npm i reasonix@canary) and never moves
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The verify step below asserts the tag actually landed.
# GitHub environment approval. Direct prerelease tag publication is disabled;
# manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Historical canary and next package identities remain readable,
# but normal publication advances only the official release.
on:
push:
tags: ['npm-v*-*']
workflow_dispatch:
inputs:
channel:
description: "Publish channel"
description: "Standalone npm recovery channel"
required: true
default: canary
default: stable
type: choice
options:
- canary
- stable
base_version:
description: "Version to publish. Canary appends -canary.<run_number>; stable publishes exactly this version."
description: "Stable version to recover exactly"
required: true
type: string
tag:
@@ -59,14 +47,30 @@ on:
required: true
type: string
orchestrated:
description: "True only when called by release-stable.yml after approval"
description: "True only when called by an approved release orchestrator"
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
preview_number:
description: "Legacy Preview ordinal for old workflow-call compatibility"
required: false
default: ""
type: string
permissions:
contents: read
concurrency:
# Serialize every publisher for one npm dist-tag. Historical canary calls
# remain ordered even though normal publication now uses Stable only.
group: release-npm-${{ inputs.channel || 'next' }}
cancel-in-progress: false
jobs:
orchestration-guard:
name: verify approved orchestrator
@@ -82,7 +86,7 @@ jobs:
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
@@ -90,7 +94,8 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
@@ -98,7 +103,7 @@ jobs:
release-gate:
name: approve standalone npm release
if: ${{ !inputs.orchestrated && !(github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') }}
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
steps:
@@ -109,7 +114,7 @@ jobs:
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') || needs.release-gate.result == 'success'))) }}
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -126,9 +131,8 @@ jobs:
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Direct prereleases/manual stable recovery pass release-gate above; canaries
# run free.
# Orchestrated releases have already passed their GitHub environment
# approval. Direct prereleases and manual Stable recovery pass release-gate.
steps:
- uses: actions/checkout@v7
with:
@@ -141,9 +145,9 @@ jobs:
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. A canary dispatch synthesizes a
# <base>-canary.<run_number> version; a manual stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
# Stable publication uses the exact npm-v* tag. build.mjs strips the
# leading `npm-`/`v`; historical Preview inputs remain only for old
# workflow-call compatibility and are not reachable from a public entry.
- name: Resolve version
id: ver
env:
@@ -154,41 +158,27 @@ jobs:
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
IN_PREVIEW_NUMBER: ${{ inputs.preview_number }}
run: bash scripts/resolve-npm-release.sh
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- env:
- name: Publish or recover immutable npm packages
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: node npm/build.mjs "$VERSION_ARG" --publish
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
# to `next`, and nothing noticed until users got downgraded. The registry
# applies tags asynchronously after publish, hence the poll.
- name: Verify dist-tags
- name: Align legacy aliases with the official release
if: ${{ inputs.channel == 'stable' }}
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
VERSION: ${{ inputs.base_version }}
APPROVED_SHA: ${{ inputs.approved_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
version="$VERSION_ARG"
version="${version#npm-}"; version="${version#v}"
case "$version" in
*-canary.*) tag=canary ;;
*-*) tag=next ;;
*) tag=latest ;;
esac
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
if [ "$got" = "$version" ]; then
echo "dist-tag $tag -> $got"
exit 0
fi
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
sleep 10
done
echo "::error::npm dist-tag '$tag' never landed on $version"
exit 1
candidate="$APPROVED_SHA"
if [ -z "$candidate" ]; then candidate="$(git rev-parse HEAD)"; fi
EXPECTED_SHA="$candidate" node scripts/finalize-npm-official-release.mjs "$VERSION"
@@ -0,0 +1,42 @@
name: Relay stable release
run-name: Relay stable release ${{ github.ref_name }}
# The tag event itself has a tag-shaped origin. Relay the immutable tag name to
# release-stable.yml on protected main-v2 so the production SignPath policy can
# allow exactly one branch name without trusting wildcard tag-like branches.
on:
push:
tags:
- 'v*'
- '!v*-*'
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected stable control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-stable.yml on main-v2
uses: actions/github-script@v9
env:
RELEASE_TAG: ${{ github.ref_name }}
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
with:
script: |
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-stable.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
tag: process.env.RELEASE_TAG,
allow_recovery: 'false',
},
});
+122 -19
View File
@@ -2,24 +2,42 @@ name: Release stable
run-name: Release stable ${{ inputs.tag || github.ref_name }}
# Stable publication has exactly one GitHub environment gate. Push the CLI,
# npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow,
# preflight verifies that all three tags point to the current main-v2 commit,
# and one GitHub approval releases every channel. A manual recovery runs the
# fixed control plane from protected main-v2 while preserving an older tagged
# candidate on main-v2 history. SignPath intentionally keeps its separate
# manual confirmations. Prerelease and canary workflows remain independently
# callable.
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
# tag to this workflow on protected main-v2. Preflight verifies that all three
# tags point to the reviewed Notes candidate on main-v2 history, and one GitHub
# approval releases every surface. After approval, a zero-publication Windows
# preflight verifies both architectures and signing stages before publication.
# Keeping the control-plane ref on main-v2 lets SignPath restrict
# production signing to that one protected origin instead of trusting wildcard
# tag-like branch names. Manual recovery uses the same fixed control plane while
# preserving an older tagged candidate on main-v2 history.
on:
push:
tags:
- 'v*'
- '!v*-*'
workflow_dispatch:
inputs:
tag:
description: "Existing stable CLI tag to recover (for example v1.18.0)"
required: true
type: string
publish_cli:
description: "Recover the CLI/Homebrew channel"
required: false
default: true
type: boolean
publish_npm:
description: "Recover the npm channel"
required: false
default: true
type: boolean
publish_desktop:
description: "Recover the Desktop/R2 channel"
required: false
default: true
type: boolean
allow_recovery:
description: "Allow an existing stable tag on main-v2 history (manual recovery only)"
required: false
default: true
type: boolean
concurrency:
group: stable-release-${{ inputs.tag || github.ref_name }}
@@ -36,6 +54,7 @@ jobs:
name: validate stable release set
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
@@ -61,10 +80,31 @@ jobs:
id: release
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- name: Revalidate normal release candidate and exact push CI
if: ${{ !inputs.allow_recovery }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_CI_WAIT_SECONDS: 60
RELEASE_VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
# which can predate its reviewed release-note entry. Carry the exact file
# validated by this protected control-plane job into both publishers.
- name: Upload reviewed release notes
uses: actions/upload-artifact@v7
with:
name: orchestrator-reviewed-release-notes
path: /tmp/release-notes.md
if-no-files-found: error
retention-days: 1
- name: Cache hit guard
run: ./scripts/cache-guard.sh
@@ -102,7 +142,8 @@ jobs:
cli:
name: publish CLI and Homebrew
needs: authorize
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_cli) }}
uses: ./.github/workflows/release.yml
with:
tag: ${{ needs.authorize.outputs.cli_tag }}
@@ -113,7 +154,8 @@ jobs:
npm:
name: publish npm
needs: authorize
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_npm) }}
uses: ./.github/workflows/release-npm.yml
with:
channel: stable
@@ -124,9 +166,10 @@ jobs:
orchestrated: true
secrets: inherit
desktop:
name: publish desktop
signpath-preflight:
name: verify stable SignPath control plane
needs: authorize
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
@@ -134,6 +177,21 @@ jobs:
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
signing_preflight: true
secrets: inherit
desktop:
name: publish desktop
needs: [authorize, signpath-preflight]
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish_desktop) }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
tag: ${{ needs.authorize.outputs.desktop_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
signing_preflight_verified: true
secrets: inherit
postflight:
@@ -142,18 +200,30 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
contents: write
steps:
- name: Require every publisher to succeed
env:
PUBLISH_CLI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_cli }}
PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
PUBLISH_DESKTOP: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do
for channel in cli npm desktop; do
selected_var="PUBLISH_${channel^^}"
result_var="${channel^^}_RESULT"
selected="${!selected_var}"
result="${!result_var}"
if [ "$selected" != "true" ]; then
echo "$channel recovery skipped; public postflight will still verify it"
continue
fi
if [ "$result" != "success" ]; then
echo "::error::stable publisher result is $result, expected success"
echo "::error::$channel stable publisher result is $result, expected success"
exit 1
fi
done
@@ -173,3 +243,36 @@ jobs:
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
run: bash scripts/verify-stable-release-artifacts.sh
- name: Publish exact Stable release record
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.authorize.outputs.version }}
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
run: |
set -euo pipefail
if ! node -e '
const catalog = require("./release-notes/releases.json");
const release = catalog.releases.find((item) => item.version === process.env.VERSION);
process.exit(release?.status === "reviewed" ? 0 : 1);
'; then
echo "Legacy Stable notes do not require a publication marker"
exit 0
fi
node scripts/release-event.mjs generate \
--version "$VERSION" --sha "$RELEASE_SHA" \
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
--output /tmp/release-event.json
existing="$(mktemp -d)"
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
echo "::error::published release-event.json differs from the approved Stable event"
exit 1
}
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
@@ -0,0 +1,62 @@
name: Request verification on released issues
# A PR that writes "Fixes #N" auto-closes its issue on merge. One that writes
# "Refs"/"Related"/"Addresses" does not, so the report stays open waiting for a
# follow-up nobody remembers to send after the release. This sends it.
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to announce (e.g. desktop-v1.18.0)
required: true
type: string
dry_run:
description: Print the targets without commenting
required: false
default: true
type: boolean
release:
types: [published]
push:
branches:
- main-v2
paths:
- '.github/workflows/release-verify-issues.yml'
- 'scripts/release-verify-issues.mjs'
- 'scripts/release-verify-issues.test.mjs'
permissions:
contents: read
issues: write
concurrency:
group: release-verify-issues-${{ github.event.inputs.tag || github.event.release.tag_name || github.ref }}
cancel-in-progress: false
jobs:
notify:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Test notifier
run: node --test scripts/release-verify-issues.test.mjs
# A push that only touched the script has no release to announce; the test
# above is the whole point of that trigger.
- name: Request verification
if: github.event_name != 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.inputs.tag || github.event.release.tag_name }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
run: |
set -euo pipefail
args=(--tag "$TAG")
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
node scripts/release-verify-issues.mjs "${args[@]}"
+340 -30
View File
@@ -1,21 +1,29 @@
name: Release
# CLI binary line. Stable releases are called by release-stable.yml after its
# single GitHub `release`-environment approval. Direct tag pushes are retained
# only for CLI prereleases so they can still ship independently; those runs keep
# their own approval gate. goreleaser auto-skips the cask upload for prereleases
# (brew has no prerelease channel — see .goreleaser.yaml).
# Native CLI binary line. Official releases are called by the protected Stable
# orchestrator. Manual dispatch remains available only for official recovery;
# historical Preview inputs below are workflow-call compatibility, not a public
# publication entrypoint.
on:
push:
tags: ['v*-*']
workflow_dispatch:
inputs:
channel:
description: "Standalone CLI recovery channel"
required: true
default: stable
type: choice
options: [stable]
tag:
description: "Existing CLI tag to publish (for example v1.18.0-rc.1)"
description: "Existing official CLI tag (for example v1.18.0)"
required: true
type: string
workflow_call:
inputs:
channel:
description: "Native CLI release channel selected by the approved orchestrator"
required: false
default: stable
type: string
tag:
description: "Existing CLI tag selected by the approved release orchestrator"
required: true
@@ -29,7 +37,17 @@ on:
required: true
type: string
orchestrated:
description: "True only when called by release-stable.yml after approval"
description: "True only when called by an approved release orchestrator"
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
required: false
default: stable
type: string
allow_preview_recovery:
description: "Legacy compatibility for already-created Preview runs"
required: false
default: false
type: boolean
@@ -37,9 +55,77 @@ on:
permissions:
contents: write # create the release and upload archives
concurrency:
# A channel pointer is a monotonic public state machine. Serialize all
# publishers for the same channel so an older recovery run cannot pass its
# read-before-write window after a newer release has published.
group: release-cli-${{ inputs.channel || 'stable' }}
cancel-in-progress: false
jobs:
resolve:
name: resolve CLI release
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
notes_version: ${{ steps.release.outputs.notes_version }}
channel: ${{ steps.release.outputs.channel }}
prerelease: ${{ steps.release.outputs.prerelease }}
sha: ${{ steps.candidate.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Resolve channel and tag
id: release
env:
EVENT_NAME: ${{ github.event_name }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
run: bash scripts/resolve-cli-release.sh
- name: Record immutable candidate
id: candidate
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
RELEASE_CHANNEL: ${{ steps.release.outputs.channel }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_ORCHESTRATOR: ${{ inputs.orchestrator }}
ALLOW_PREVIEW_RECOVERY: ${{ inputs.allow_preview_recovery }}
run: |
set -euo pipefail
git fetch origin main-v2
sha="$(git rev-parse "$RELEASE_TAG^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/main-v2; then
echo "::error::$RELEASE_TAG points to $sha, which is not on main-v2 history"
exit 1
fi
if [ "$ALLOW_PREVIEW_RECOVERY" = "true" ]; then
if [ "$IN_ORCHESTRATED" != "true" ] || [ "$IN_ORCHESTRATOR" != "preview" ] || [ "$RELEASE_CHANNEL" != "preview" ]; then
echo "::error::Preview recovery requires the approved Preview orchestrator"
exit 1
fi
elif [ "$RELEASE_CHANNEL" = "preview" ] && [ "$sha" != "$(git rev-parse origin/main-v2)" ]; then
echo "::error::CLI Preview must tag current main-v2; $RELEASE_TAG points to $sha"
exit 1
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- name: Verify existing protected tag
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
APPROVED_SHA: ${{ steps.candidate.outputs.sha }}
VERIFY_RELEASE_CHECKOUT: false
run: bash scripts/verify-release-tag.sh
orchestration-guard:
name: verify approved orchestrator
needs: resolve
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
@@ -52,7 +138,7 @@ jobs:
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-stable.yml@{1}', github.repository, github.ref) }}
EXPECTED_CALLER_WORKFLOW_REF: ${{ format('{0}/.github/workflows/release-{1}.yml@{2}', github.repository, inputs.orchestrator, github.ref) }}
CALLER_EVENT_NAME: ${{ github.event_name }}
CALLER_REF: ${{ github.ref }}
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
@@ -60,6 +146,7 @@ jobs:
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
@@ -68,23 +155,25 @@ jobs:
release-gate:
name: approve standalone CLI release
needs: resolve
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone CLI release $RELEASE_TAG"
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
run: echo "Approved standalone CLI $RELEASE_CHANNEL release $RELEASE_TAG"
cache-guard:
name: cache hit guard
needs: [orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
needs: [resolve, orchestration-guard, release-gate]
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -93,17 +182,39 @@ jobs:
goreleaser:
name: archives + checksums + homebrew tap
needs: cache-guard
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# The stable caller has already passed the single GitHub release approval.
# Standalone prereleases pass release-gate above. This job therefore must not
# add a second GitHub environment approval.
# The Stable caller has already passed the single GitHub release approval.
# Official standalone recovery passes release-gate above. This job therefore
# must not add a second GitHub environment approval.
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
ref: ${{ needs.resolve.outputs.sha }}
# Recovery may build an immutable tag that predates the current recovery
# policy. Keep product sources pinned above, but execute publication
# decisions from the protected workflow commit.
- uses: actions/checkout@v7
with:
fetch-depth: 0
path: release-control
ref: ${{ github.workflow_sha }}
- name: Isolate release-control checkout from product git state
run: |
set -euo pipefail
git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir)"
exclude_file="$git_common_dir/info/exclude"
if ! grep -qxF '/release-control/' "$exclude_file"; then
printf '%s\n' '/release-control/' >> "$exclude_file"
fi
git check-ignore -q release-control/
dirty="$(git status --porcelain --untracked-files=all)"
if [ -n "$dirty" ]; then
printf 'product checkout is dirty before release:\n%s\n' "$dirty" >&2
exit 1
fi
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -111,38 +222,237 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Download orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
uses: actions/download-artifact@v8
with:
name: orchestrator-reviewed-release-notes
path: /tmp/orchestrator-reviewed-release-notes
- name: Use orchestrator-reviewed release notes
if: ${{ inputs.orchestrated }}
run: |
test -s /tmp/orchestrator-reviewed-release-notes/release-notes.md
cp /tmp/orchestrator-reviewed-release-notes/release-notes.md /tmp/release-notes.md
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
if: ${{ inputs.orchestrated }}
env:
RELEASE_TAG: ${{ inputs.tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
run: bash scripts/verify-release-tag.sh
- name: Decide whether CLI artifacts need publication
id: publication
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
CHANNEL: ${{ needs.resolve.outputs.channel }}
PRERELEASE: ${{ needs.resolve.outputs.prerelease }}
run: |
set -euo pipefail
validation_channel="$CHANNEL"
if [ "$CHANNEL" = "stable" ] && [ "$PRERELEASE" = "true" ]; then
validation_channel=any
fi
release_json=/tmp/existing-cli-release.json
release_error=/tmp/existing-cli-release.error
checksums=/tmp/existing-cli-release-SHA256SUMS
if gh api "repos/${{ github.repository }}/releases/tags/$TAG" \
>"$release_json" 2>"$release_error"; then
gh release download "$TAG" -R "${{ github.repository }}" \
--pattern SHA256SUMS --output "$checksums"
decision="$(
bash release-control/scripts/decide-cli-release-publication.sh \
"$validation_channel" "$TAG" "${{ github.repository }}" \
"$release_json" "$checksums"
)"
echo "existing CLI release $TAG is complete and checksum-bound; reusing it"
elif grep -Eiq 'HTTP 404|Not Found' "$release_error"; then
decision="$(
bash release-control/scripts/decide-cli-release-publication.sh \
"$validation_channel" "$TAG" "${{ github.repository }}" - -
)"
echo "CLI release $TAG does not exist; GoReleaser will publish it"
else
cat "$release_error" >&2
exit 1
fi
test "$decision" = "publish" -o "$decision" = "reuse"
echo "decision=$decision" >> "$GITHUB_OUTPUT"
- uses: goreleaser/goreleaser-action@v7
if: ${{ steps.publication.outputs.decision == 'publish' }}
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
# though checkout is on the release tag. Pin GoReleaser explicitly, and
# avoid ambiguity from the three release tags sharing one commit.
GORELEASER_CURRENT_TAG: ${{ inputs.tag || github.ref_name }}
GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }}
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
run: gh release edit "$TAG" --notes-file /tmp/release-notes.md
- name: Publish CLI release metadata to R2
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping CLI release metadata"
exit 0
fi
channel=""
if [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
channel="stable"
elif [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$ ]]; then
channel="preview"
else
echo "internal CLI release $TAG; publishing only immutable metadata"
fi
required_assets='[
"reasonix-darwin-amd64.tar.gz",
"reasonix-darwin-arm64.tar.gz",
"reasonix-linux-amd64.tar.gz",
"reasonix-linux-arm64.tar.gz",
"reasonix-windows-amd64.zip",
"reasonix-windows-arm64.zip",
"SHA256SUMS"
]'
gh api "repos/${{ github.repository }}/releases/tags/$TAG" > /tmp/cli-release.raw.json
jq --arg tag "$TAG" --arg notes_tag "$NOTES_TAG" --argjson required "$required_assets" '
if .tag_name != $tag then error("release tag mismatch") else . end |
if .draft then error("draft release cannot be published") else . end |
. as $release |
($release.assets | map({key: .name, value: .}) | from_entries) as $assets |
if ($required | all(. as $name | $assets[$name] != null))
then {
tag_name: $release.tag_name,
prerelease: $release.prerelease,
html_url: $release.html_url,
release_notes_url: ("https://reasonix.io/changelog/" + $notes_tag + "/"),
assets: [
$required[] as $name |
$assets[$name] |
{
name: .name,
browser_download_url: .browser_download_url,
size: .size
}
]
}
else error("release is missing one or more required CLI assets")
end
' /tmp/cli-release.raw.json > /tmp/cli-release.json
if [ -n "$channel" ]; then
bash scripts/validate-cli-release-manifest.sh \
"$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.json "$NOTES_TAG"
else
bash scripts/validate-cli-release-manifest.sh \
any "$TAG" "${{ github.repository }}" /tmp/cli-release.json "$NOTES_TAG"
fi
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
validation_channel="${channel:-any}"
immutable_key="cli/releases/${TAG}/latest.json"
immutable_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
--endpoint-url "$endpoint" 2>"$immutable_error"; then
bash scripts/validate-cli-release-manifest.sh \
"legacy-${validation_channel}" "$TAG" "${{ github.repository }}" \
/tmp/cli-release.immutable.json "$NOTES_TAG"
if ! bash scripts/compare-cli-release-manifests.sh \
/tmp/cli-release.json /tmp/cli-release.immutable.json; then
echo "::error::immutable CLI release metadata for $TAG already exists with different content"
exit 1
fi
echo "immutable CLI release metadata for $TAG already exists; preserving it"
elif grep -Eiq '404|NoSuchKey|Not Found' "$immutable_error"; then
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/${immutable_key}" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable"
else
cat "$immutable_error" >&2
exit 1
fi
rm -f "$immutable_error"
aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
--endpoint-url "$endpoint"
bash scripts/validate-cli-release-manifest.sh \
"legacy-${validation_channel}" "$TAG" "${{ github.repository }}" \
/tmp/cli-release.immutable.json "$NOTES_TAG"
bash scripts/compare-cli-release-manifests.sh \
/tmp/cli-release.json /tmp/cli-release.immutable.json
if [ -z "$channel" ]; then
echo "internal CLI release $TAG; Stable and Preview pointers remain unchanged"
exit 0
fi
current_tag=""
pointer_error="$(mktemp)"
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
--endpoint-url "$endpoint" 2>"$pointer_error"; then
current_tag="$(jq -er '.tag_name | strings' /tmp/cli-release.pointer.json)"
bash scripts/validate-cli-release-manifest.sh \
"legacy-${channel}" "$current_tag" "${{ github.repository }}" \
/tmp/cli-release.pointer.json "$current_tag"
elif grep -Eiq '404|NoSuchKey|Not Found' "$pointer_error"; then
echo "CLI $channel pointer does not exist yet"
else
cat "$pointer_error" >&2
exit 1
fi
rm -f "$pointer_error"
pointer_manifest=-
if [ -n "$current_tag" ]; then
pointer_manifest=/tmp/cli-release.pointer.json
fi
pointer_decision="$(
bash scripts/decide-cli-pointer-update.sh \
"$channel" /tmp/cli-release.json "$pointer_manifest"
)"
if [ "$pointer_decision" = "skip" ]; then
echo "CLI $channel pointer remains ${current_tag:-unset}; candidate $TAG is not newer and needs no repair"
exit 0
fi
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
--endpoint-url "$endpoint"
bash scripts/validate-cli-release-manifest.sh \
"$channel" "$TAG" "${{ github.repository }}" \
/tmp/cli-release.pointer.json "$NOTES_TAG"
cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json
echo "CLI $channel pointer -> $TAG"
- name: Attach desktop manifest compatibility asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
@@ -184,7 +494,7 @@ jobs:
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
@@ -231,7 +541,7 @@ jobs:
# owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ inputs.tag || github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
case "$TAG" in
+68
View File
@@ -0,0 +1,68 @@
name: Sweep stale version reports
# Reports filed many minors back cannot be confirmed against a current build,
# and 65% of the open tracker has never had a reply. This asks, waits, then
# closes — never closing anything that was not asked first.
on:
workflow_dispatch:
inputs:
dry_run:
description: Print what would happen without commenting
required: false
default: true
type: boolean
limit:
description: Maximum issues to touch in this run
required: false
default: '40'
type: string
schedule:
- cron: '17 3 * * 1'
push:
branches:
- main-v2
paths:
- '.github/workflows/stale-report-sweep.yml'
- 'scripts/stale-report-sweep.mjs'
- 'scripts/stale-report-sweep.test.mjs'
permissions:
contents: read
issues: write
concurrency:
group: stale-report-sweep
cancel-in-progress: false
jobs:
sweep:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Test sweeper
run: node --test scripts/stale-report-sweep.test.mjs
# A push that only touched the sweeper runs its tests and stops there.
- name: Sweep
if: github.event_name != 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LIMIT: ${{ github.event.inputs.limit || '40' }}
# Posting stays off until this repository variable is set to "true", so
# merging the sweeper cannot start commenting on its own.
ENABLED: ${{ vars.STALE_SWEEP_ENABLED }}
REQUESTED_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }}
run: |
set -euo pipefail
args=(--limit "$LIMIT")
if [ "$ENABLED" != "true" ] || [ "$REQUESTED_DRY_RUN" = "true" ]; then
args+=(--dry-run)
echo "::notice::dry run (STALE_SWEEP_ENABLED=${ENABLED:-unset})"
fi
node scripts/stale-report-sweep.mjs "${args[@]}"
+2
View File
@@ -71,3 +71,5 @@ temp/
# Official theme art intermediate outputs / caches
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
agent-complexity-report.txt
site/.generated/
+40
View File
@@ -0,0 +1,40 @@
# Report-only complexity monitor for internal/agent production code.
# Uses funlen + cyclop with generous thresholds so existing code does not
# generate a flood of noise. issues-exit-code is forced to 0 by the CI step so
# this never blocks merges while we establish a baseline and track trend.
version: "2"
run:
tests: false
linters:
default: none
enable:
- funlen
- cyclop
settings:
funlen:
# Deliberately above a hard 50-line rule: length alone is not the
# acceptance gate. This surfaces only the remaining large hotspots after
# the Run/executeOne extraction so trend can be tracked.
lines: 120
statements: 80
ignore-comments: true
cyclop:
max-complexity: 30
package-average: 0
skip-tests: true
issues:
max-issues-per-linter: 0
max-same-issues: 0
# Report mode: never fail the job. Baseline/trend is the gate, not a hard cut.
# The CI step also passes --issues-exit-code=0 for older golangci-lint builds.
uniq-by-line: false
output:
formats:
text:
path: stdout
print-linter-name: true
print-issued-lines: true
+3 -2
View File
@@ -34,8 +34,9 @@ checksum:
homebrew_casks:
- name: reasonix
ids: [reasonix]
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
# Prereleases (including v*-preview.* and v*-rc*) must not update the stable
# tap; brew has no separate prerelease channel, so uploading either cask
# would make it the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Canary-only configuration. SignPath test certificates intentionally do not
chain to a Windows trusted root, so authenticode-verify cannot be used for
the already test-signed payload. The workflow verifies that every payload
executable is signed and hash-identical to the rebuilt portable package.
Stable and RC releases use windows-installer-v2 instead. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="*installer*.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Sign the finished NSIS container only when the exact payload used to build it
is present and already Authenticode-signed. GitHub Actions wraps this flat
bundle in a zip before SignPath receives it. Keep this versioned separately
so provisioning it cannot break releases still using windows-installer. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="*installer*.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-desktop.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-guard.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-launcher.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-update-helper.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-cli.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-uninstall.exe">
<authenticode-verify />
</pe-file>
</zip-file>
</artifact-configuration>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Sign every executable that the NSIS installer writes to disk. GitHub Actions
wraps the flat payload directory in this zip before SignPath receives it. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="reasonix-desktop.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-guard.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-launcher.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-update-helper.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-cli.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-uninstall.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
+19
View File
@@ -0,0 +1,19 @@
version: 1
project_slug: DeepSeek-Reasonix
signing_policy_slug: release-signing
repository_url: https://github.com/esengine/DeepSeek-Reasonix.git
allowed_branch_names:
- main-v2
allowed_build_definitions:
- .github/workflows/release-stable.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-desktop.yml
- .signpath/artifact-configurations/windows-payload.xml
- .signpath/artifact-configurations/windows-installer-v2.xml
- scripts/complete-signpath-request.ps1
- scripts/package-windows-desktop.sh
- scripts/verify-windows-authenticode.ps1
- cmd/signpath-contract/main.go
+27
View File
@@ -69,6 +69,33 @@ branch.
### Fixed
- Fixed Remote Workbench failing with only `initialize: workbench-desktop:
connection closed` on fresh or cross-platform SSH hosts. Desktop now proves
the exact Host CLI Build ID, provisions the matching verified release without
requiring remote npm, runs the managed binary explicitly, and preserves a
safe structured bootstrap error when the remote command exits early.
- Hardened Bash permission reuse for dynamic and indirect execution. Parameter/arithmetic expansions,
assignments, redirects, heredocs, and globs can only be remembered as exact
`Bash=<literal>` rules, while still using Auto's normal fallback. Nested or
indirect execution now requires a human in interactive Ask/Auto and fails
closed in headless Ask/Auto/DontAsk. Broad Bash rules, Guardian/hook allows,
and the approved-plan window can no longer silently authorize that stricter
class; YOLO remains the explicit full-access bypass and sandbox enforcement
is unchanged.
- Fixed Desktop sessions incorrectly locking themselves during Goal + Delivery
mode changes, controller rebuilds, duplicate-tab restore, and background
reattachment. Desktop now keeps one process-local runtime owner per canonical
session, fences stale controller events by runtime epoch, blocks sends until
that runtime is ready, and scopes single-instance ownership to
`REASONIX_HOME` instead of the executable path. Switching saved sessions is
now transactional: a target build, restore, or lease failure leaves the
current controller, lease, path, mode profile, and runtime epoch untouched.
- Stabilized the desktop rich composer caret after skill and plugin invocation
tags. DOM→model and model→DOM selection mapping now treat invocation chips as
zero-length atoms while still counting user text that lands inside the NBSP
caret anchor (common on Windows WebView2), restore both selection ends, and
recover the insertion point from a `beforeinput` snapshot when the browser
temporarily loses selection — so mid-text edits no longer jump to the end.
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
exited proxy client cannot leave the embedded UI hidden during startup. If
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
+2 -1
View File
@@ -139,8 +139,9 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
+1
View File
@@ -131,6 +131,7 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.zh-CN.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
+12 -6
View File
@@ -17,13 +17,19 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
## Memory
- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md`
(personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any
`REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name.
- Standing instructions are hierarchical: committed/shared `REASONIX.md`,
`AGENTS.md`, and `CLAUDE.md`; personal `*.local.md` variants; matching files in
ancestor directories; and user-global files under the memory state root
(`REASONIX_STATE_HOME`, otherwise `REASONIX_HOME`, otherwise `~/.reasonix` on
macOS/Linux or `%APPDATA%\reasonix` on Windows). All distinct supported files
in a directory load; `AGENTS.md` is not merely a fallback.
- `@path` on its own line imports another file's contents.
- `#<note>` in chat quick-adds a line here. The `remember` tool saves durable
facts to the per-project auto-memory store (frontmatter files + `MEMORY.md`
index), which loads into the prefix on the next session.
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
index). Fact `type` classifies content; independent `scope` controls whether it
is project-only (the default) or explicitly global. The index loads into the
stable prefix on the next session; global user/feedback bodies also load as
lower-priority compatibility guidance. The current turn receives a tail note.
## Notes
-358
View File
@@ -1,358 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
"reasonix/internal/repair"
)
func runAssist(args []string) int {
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
root := fs.String("root", ".", "project root to diagnose")
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
network := fs.Bool("network", true, "include provider connectivity diagnostics")
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := requestRepairPlan(ctx, *root, *model, report)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
Preview []repair.RepairPlanPreview `json:"preview"`
}{plan, preview}); code != 0 {
return code
}
} else {
printPlanPreview(plan, preview)
}
if !*apply {
return 0
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func runApplyPlan(args []string) int {
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
root := fs.String("root", ".", "project root")
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
}
b, err := os.ReadFile(*file)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := repair.DecodeRepairPlan(b)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if !*jsonOut {
printPlanPreview(plan, preview)
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
cfg, err := loadAIProviderConfig(root)
if err != nil {
return repair.RepairPlan{}, err
}
if strings.TrimSpace(modelRef) == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
}
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": entry.Effort, "reasoning_protocol": entry.ReasoningProtocol,
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": cfg.NetworkProxySpec(),
},
})
if err != nil {
return repair.RepairPlan{}, err
}
safeReport, snapshotAliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
return repair.RepairPlan{}, err
}
stream, err := p.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
{Role: provider.RoleUser, Content: string(payload)},
},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
return repair.RepairPlan{}, err
}
var out strings.Builder
for chunk := range stream {
switch chunk.Type {
case provider.ChunkText:
out.WriteString(chunk.Text)
case provider.ChunkError:
if chunk.Err != nil {
return repair.RepairPlan{}, chunk.Err
}
}
}
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
if err != nil {
return repair.RepairPlan{}, err
}
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
return cfg, nil
}
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
for _, snapshot := range snapshots {
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
if loadErr == nil {
return cfg, nil
}
}
}
// Built-in recovery defaults resolve the global credential file without
// reading, migrating, or rewriting malformed user/project TOML.
return config.LoadRecoveryDefaultsForRoot(root), nil
}
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
Allowed actions only (return an empty actions array when no safe action applies):
- repair_config with scope global or project
- restore_snapshot with snapshotId
- rebuild_derived_state with target tabs, projects, window, zoom, or all
- rollback_update with no parameters
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
// It deliberately carries no free-form diagnostic strings or state metadata:
// finding messages can quote config lines, URLs, commands, and permission
// rules, while snapshot and update metadata can be edited on disk. Only fixed
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
// machine.
type providerSafeReport struct {
Root string `json:"root"`
Network bool `json:"network"`
Snapshots []providerSafeSnapshot `json:"snapshots"`
PendingUpdate bool `json:"pendingUpdate"`
Findings []providerSafeFinding `json:"findings"`
}
type providerSafeSnapshot struct {
ID string `json:"id"`
}
type providerSafeFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Scope string `json:"scope,omitempty"`
}
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
safe := providerSafeReport{
Root: "<project>",
Network: report.Network,
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
PendingUpdate: report.PendingUpdate != nil,
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
}
snapshotAliases := make(map[string]string, len(report.Snapshots))
for _, snapshot := range report.Snapshots {
if strings.TrimSpace(snapshot.ID) != "" {
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
snapshotAliases[alias] = snapshot.ID
}
}
for _, finding := range report.Findings {
safe.Findings = append(safe.Findings, providerSafeFinding{
Severity: providerSafeSeverity(finding.Severity),
Code: providerSafeDiagnosticCode(finding.Code),
Scope: providerSafeScope(finding.Scope),
})
}
return safe, snapshotAliases
}
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
resolved := plan
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
for i := range resolved.Actions {
action := &resolved.Actions[i]
if action.Type != "restore_snapshot" {
continue
}
id, ok := aliases[action.SnapshotID]
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
}
action.SnapshotID = id
}
return resolved, nil
}
func providerSafeSeverity(severity string) string {
switch severity {
case "error", "warning", "info":
return severity
default:
return "unknown"
}
}
func providerSafeDiagnosticCode(code string) string {
switch code {
case "config.invalid_toml", "config.load_failed",
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
"provider.invalid_key_name", "provider.missing_key",
"model.no_default", "model.invalid_default",
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
"permissions.conflict", "file.permissions",
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
"derived.invalid_json",
"network.invalid_proxy", "network.client_failed", "network.unreachable",
"network.authentication_failed", "network.ok", "network.unexpected_status":
return code
default:
return "unknown"
}
}
// providerSafeScope generalizes a finding scope to a closed vocabulary.
// derived:* names survive because rebuild_derived_state needs the target and
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
// and collapse to their category; anything unrecognized becomes "other".
func providerSafeScope(scope string) string {
scope = strings.TrimSpace(scope)
switch {
case scope == "":
return ""
case strings.HasPrefix(scope, "derived:"):
switch scope[len("derived:"):] {
case "tabs", "projects", "window", "zoom":
return scope
}
return "derived:other"
case strings.HasPrefix(scope, "provider:"):
return "provider"
case strings.HasPrefix(scope, "plugin:"):
return "plugin"
}
switch scope {
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
return scope
}
return "other"
}
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
fmt.Println("AI repair plan:", plan.Summary)
for _, preview := range previews {
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
if preview.Diff != "" {
fmt.Println(preview.Diff)
}
}
}
func confirmPlan() bool {
fmt.Print("Apply this repair plan? [y/N] ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
-144
View File
@@ -1,144 +0,0 @@
package main
import (
"encoding/json"
"strings"
"testing"
"reasonix/internal/repair"
)
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
// quote config lines, MCP command lines, permission rules, URLs with
// credentials, emails, tokens, or absolute paths.
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
const metadataSecret = "snapshot-metadata-secret"
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
secrets := []string{
"/Users/someone/dotfiles",
"[email protected]",
"sk-live-Abc123Secret",
"npx --api-key=sk-live-Abc123Secret mcp-server",
"bash(rm -rf *)",
"corp-internal-llm",
"https://user:[email protected]:8080",
metadataSecret,
"secret.finding.code",
"secret-severity",
snapshotID,
}
report := repair.DiagnosticReport{
GeneratedAt: metadataSecret,
Root: "/Users/someone/dotfiles",
Network: true,
Snapshots: []repair.DiagnosticSnapshot{
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
},
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
Findings: []repair.DiagnosticFinding{
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
Message: "The configured API key is missing."},
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
Message: "Derived desktop state desktop-tabs.json is malformed."},
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
Message: metadataSecret, Remediation: metadataSecret},
},
}
safeReport, aliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
t.Fatal(err)
}
body := string(payload)
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
}
}
for _, want := range []string{
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
`"code":"config.invalid_toml"`,
`"scope":"provider"`,
`"scope":"plugin"`,
`"scope":"derived:tabs"`,
`"id":"snapshot-1"`,
`"id":"snapshot-2"`,
`"pendingUpdate":true`,
`"severity":"unknown"`,
`"code":"unknown"`,
`"scope":"other"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("outbound payload missing %s:\n%s", want, body)
}
}
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
t.Fatalf("snapshot alias map = %v", aliases)
}
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
if strings.Contains(body, `"`+forbiddenField+`"`) {
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
}
}
}
func TestResolveProviderSnapshotAliases(t *testing.T) {
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
plan, err := repair.DecodeRepairPlan([]byte(`{
"schemaVersion": 1,
"summary": "restore newest snapshot",
"actions": [
{"type":"repair_config","scope":"global","reason":"invalid config"},
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
]
}`))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
if err != nil {
t.Fatal(err)
}
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
}
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
t.Fatalf("alias resolution mutated source plan: %q", got)
}
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
t.Fatal("unknown snapshot alias was accepted")
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
"global": "global",
"project": "project",
"derived:tabs": "derived:tabs",
"derived:zoom": "derived:zoom",
"derived:/etc/passwd": "derived:other",
"provider:corp-llm": "provider",
"plugin:internal-mcp": "plugin",
"credential file": "credential file",
"[email protected]": "other",
"/Users/someone/project": "other",
}
for in, want := range cases {
if got := providerSafeScope(in); got != want {
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
}
}
}
-398
View File
@@ -1,398 +0,0 @@
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
// desktop shell. It is packaged beside the desktop application so recovery
// remains available when Wails, WebView, or user configuration cannot start.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/repair"
)
var version = "dev"
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
return runLaunch(nil)
}
switch args[0] {
case "check":
return runCheck(args[1:], false)
case "repair":
return runCheck(args[1:], true)
case "launch":
return runLaunch(args[1:])
case "recover":
return runRecover(args[1:])
case "snapshots":
return runSnapshots(args[1:])
case "restore":
return runRestore(args[1:])
case "undo":
return runUndo(args[1:])
case "diagnose":
return runDiagnose(args[1:])
case "rebuild":
return runRebuild(args[1:])
case "assist":
return runAssist(args[1:])
case "apply-plan":
return runApplyPlan(args[1:])
case "version", "--version", "-v":
fmt.Println("reasonix-guard", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
if isMacBundleLauncher() {
return runLaunch(append([]string{"--"}, args...))
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
usage()
return 2
}
}
func runDiagnose(args []string) int {
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
network := fs.Bool("network", false, "probe provider endpoints and credentials")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(report); code != 0 {
return code
}
} else {
fmt.Println("Reasonix Guard diagnostics")
if len(report.Findings) == 0 {
fmt.Println(" ok: no issues found")
}
for _, finding := range report.Findings {
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
}
}
if report.HasErrors() {
return 1
}
return 0
}
func runRebuild(args []string) int {
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
target := fs.String("target", "", "tabs|projects|window|zoom|all")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
return 2
}
applied, err := repair.RebuildDerivedState(*target)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(applied)
}
for _, path := range applied {
fmt.Println("quarantined:", path)
}
return 0
}
func runSnapshots(args []string) int {
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
snapshots, err := repair.ListConfigSnapshots()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(snapshots)
}
for _, snap := range snapshots {
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
}
return 0
}
func runRestore(args []string) int {
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
snapshot := fs.String("snapshot", "", "snapshot id to restore")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
return 2
}
tx, err := repair.RestoreConfigSnapshot(*snapshot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
return 0
}
func runUndo(args []string) int {
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
tx, err := repair.UndoLastRepair()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(tx)
}
fmt.Println("undid repair:", tx.ID)
return 0
}
func printJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func runCheck(args []string, apply bool) int {
name := "reasonix-guard check"
if apply {
name = "reasonix-guard repair"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return 1
}
} else {
fmt.Println("Reasonix Guard")
for _, check := range report.Checks {
status := "ok"
if !check.Exists {
status = "missing"
} else if !check.Valid {
status = "invalid"
}
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
if check.Error != "" {
fmt.Println(" ", check.Error)
}
}
for _, action := range report.Applied {
fmt.Println(" applied:", action)
}
}
for _, check := range report.Checks {
if check.Exists && !check.Valid {
return 1
}
}
return 0
}
// failedInstallBlocksLaunch decides whether startup must stop after a failed
// RecoverFailedInstall. The failure marker means the installer may have left
// the release unit partially replaced, so the binaries cannot be presumed
// coherent until a rollback completes: any error with the restore unfinished
// fails closed. MixedInstall alone is not the signal — a rollback that failed
// before touching anything (e.g. while staging) reports MixedInstall=false yet
// leaves the installer's half-written unit in place. Only a completed restore
// (RolledBack, with just the marker cleanup failing) is safe to launch.
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
return err != nil && !result.RolledBack
}
func runLaunch(args []string) int {
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
app := fs.String("app", "", "desktop executable path")
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
if err := fs.Parse(args); err != nil {
return 2
}
// An update helper that watched the installer fail records a marker (it
// cannot roll back itself from outside the install directory). Restore the
// previous release unit immediately instead of waiting for a crash loop.
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
if failedInstallBlocksLaunch(result, err) {
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode
if !*safeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
// The compensated install is still a coherent (new-version) release
// unit, so a Safe Mode boot is a sound fallback while the pending
// transaction stays on disk for the next rollback attempt.
useSafeMode = true
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
_ = tracker.MarkClean()
} else {
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
}
useSafeMode = true
case recoverySafeMode:
useSafeMode = true
case recoveryQuit:
return 0
}
}
}
path := strings.TrimSpace(*app)
if path == "" {
path = siblingDesktopExecutable()
}
if path == "" {
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
return 1
}
childArgs := append([]string(nil), fs.Args()...)
if useSafeMode {
childArgs = append([]string{"--safe-mode"}, childArgs...)
}
cmd := exec.Command(path, childArgs...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if *detach {
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
if err := cmd.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok {
return exit.ExitCode()
}
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func packagedDetachedLauncher() bool {
if runtime.GOOS != "windows" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
name := strings.ToLower(filepath.Base(exe))
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
}
func runRecover(args []string) int {
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
app := fs.String("app", "", "desktop executable path")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
return 1
}
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
case recoverySafeMode:
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
default:
return 0
}
}
func siblingDesktopExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
name := "reasonix-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
if runtime.GOOS == "darwin" {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func isMacBundleLauncher() bool {
if runtime.GOOS != "darwin" {
return false
}
exe, err := os.Executable()
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
}
func usage() {
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/repair"
)
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestFailedInstallBlocksLaunch(t *testing.T) {
cases := []struct {
name string
result repair.UpdateRollbackResult
err error
want bool
}{
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
}
for _, tc := range cases {
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
}
}
}
-9
View File
@@ -1,9 +0,0 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
-24
View File
@@ -1,24 +0,0 @@
//go:build darwin
package main
import (
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return recoveryRepair
case "Safe Mode":
return recoverySafeMode
default:
return recoveryQuit
}
}
-36
View File
@@ -1,36 +0,0 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
if path, err := exec.LookPath("zenity"); err == nil {
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
"--text=Reasonix failed to start repeatedly.",
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
out, err := cmd.Output()
if strings.TrimSpace(string(out)) == "Quit" {
return recoveryQuit
}
if err == nil {
return recoveryRepair
}
return recoverySafeMode
}
if path, err := exec.LookPath("kdialog"); err == nil {
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
if err == nil {
return recoveryRepair
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return recoveryQuit
}
}
return recoverySafeMode
}
-5
View File
@@ -1,5 +0,0 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
-27
View File
@@ -1,27 +0,0 @@
//go:build windows
package main
import (
"os/exec"
"strings"
"syscall"
)
func nativeRecoveryChoice() recoveryChoice {
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Yes":
return recoveryRepair
case "No":
return recoverySafeMode
default:
return recoveryQuit
}
}
+32
View File
@@ -0,0 +1,32 @@
// Command reasonix-launcher is the permanent thin desktop entry point for the
// versioned install layout (v1.20+). It reads InstallRoot/current.json, starts
// the active reasonix-desktop binary, and exits. It never counts crashes,
// chooses previous versions, or enters a product safe mode.
//
// Migration compatibility: old shortcuts may still pass "launch", "--detach",
// or "--safe-mode". Those tokens are stripped and ignored.
package main
import (
"os"
"reasonix/internal/desktoplauncher"
)
var version = "dev"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
return desktoplauncher.Run(args, version)
}
func resolveDesktopPath(installRoot string) (string, error) {
return desktoplauncher.ResolveDesktopPath(installRoot)
}
func stripLegacyLaunchArgs(args []string) []string {
return desktoplauncher.StripLegacyLaunchArgs(args)
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
)
func TestStripLegacyLaunchArgs(t *testing.T) {
got := stripLegacyLaunchArgs([]string{
"launch", "--detach", "--safe-mode", "--app", `C:\Old\reasonix-desktop.exe`, "--foo", "bar",
})
want := []string{"--foo", "bar"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %#v want %#v", got, want)
}
got = stripLegacyLaunchArgs([]string{"--app=ignored", "keep"})
if len(got) != 1 || got[0] != "keep" {
t.Fatalf("got %#v", got)
}
got = stripLegacyLaunchArgs([]string{"--", "--safe-mode", "x"})
if len(got) != 3 || got[0] != "--" {
t.Fatalf("separator not preserved: %#v", got)
}
}
func TestResolveDesktopPathUsesCurrentJSON(t *testing.T) {
root := t.TempDir()
src := t.TempDir()
version := "v1.20.0"
members := make([]installlayout.Member, 0, 3)
for _, name := range installlayout.AllowedVersionMembers() {
path := filepath.Join(src, name)
if err := os.WriteFile(path, []byte("bin-"+name), 0o755); err != nil {
t.Fatal(err)
}
members = append(members, installlayout.Member{Name: name, Path: path})
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: root,
Version: version,
RequestID: "launcher-test",
Members: members,
}); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(root, "versions", version, installlayout.DesktopBinaryName())
if path != want {
t.Fatalf("path=%s want=%s", path, want)
}
}
func TestResolveDesktopPathFallsBackToFlatSibling(t *testing.T) {
root := t.TempDir()
flat := filepath.Join(root, installlayout.DesktopBinaryName())
if err := os.WriteFile(flat, []byte("flat"), 0o755); err != nil {
t.Fatal(err)
}
path, err := resolveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if path != flat {
t.Fatalf("path=%s want=%s", path, flat)
}
}
func TestResolveDesktopPathRejectsCorruptPointer(t *testing.T) {
root := t.TempDir()
// Write a current.json that fails schema validation.
if err := os.WriteFile(filepath.Join(root, installlayout.CurrentFileName), []byte(`{"schemaVersion":99}`), 0o644); err != nil {
t.Fatal(err)
}
// Even with a flat sibling, a corrupt pointer must fail closed. Falling back
// would silently run a stale executable after a partially observed update.
if err := os.WriteFile(filepath.Join(root, installlayout.DesktopBinaryName()), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if path, err := resolveDesktopPath(root); err == nil {
t.Fatalf("corrupt current.json resolved stale flat desktop %q", path)
}
}
+525
View File
@@ -0,0 +1,525 @@
// Command reasonix-legacy-migrator is the one-shot flat→versioned install
// migrator used by v1.20+ packaging. In compatibility payloads it is still
// named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
// and behavior are intentionally separate from the old Guard recovery product.
//
// It only: acquires a migration lock, validates the flat release unit, creates
// versions/<version>, writes current.json, rewrites entry points, starts the
// thin launcher, and self-deletes when possible. It never chooses safe mode,
// counts crashes, or auto-rolls back.
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"reasonix/internal/desktoplauncher"
"reasonix/internal/fileutil"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
var version = "dev"
const migrationLockName = ".reasonix-layout-migrate.lock"
func main() {
if runningAsLauncher() {
os.Exit(desktoplauncher.Run(os.Args[1:], version))
}
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 1 {
switch args[0] {
case "version", "--version", "-v":
fmt.Println("reasonix-legacy-migrator", version)
return 0
case "help", "--help", "-h":
fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
return 0
}
}
installRoot := ""
activeVersion := strings.TrimSpace(version)
activateStaging := ""
relaunch := true
for i := 0; i < len(args); i++ {
switch args[i] {
case "--install-root":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
return 2
}
i++
installRoot = args[i]
case "--version":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --version requires a value")
return 2
}
i++
activeVersion = args[i]
case "--activate-staging":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
return 2
}
i++
activateStaging = args[i]
case "launch", "--detach", "--safe-mode":
// Accept legacy argv from old shortcuts; no product behavior.
continue
case "--no-relaunch":
relaunch = false
continue
case "--app":
if i+1 < len(args) {
i++
}
continue
}
}
if installRoot == "" {
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
installRoot = filepath.Dir(exe)
}
installRoot = filepath.Clean(installRoot)
if activateStaging != "" {
activeVersion, err := normalizeActiveVersion(activeVersion)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if relaunch {
_ = startLauncher(installRoot)
}
return 0
}
if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func migrate(installRoot, activeVersion string) error {
return migrateWithRelaunch(installRoot, activeVersion, true)
}
func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
var err error
activeVersion, err = normalizeActiveVersion(activeVersion)
if err != nil {
return err
}
unlock, err := acquireMigrationLock(installRoot)
if err != nil {
return err
}
defer unlock()
// Pointer already committed: only cleanup, never overwrite the active version.
// A present but corrupt pointer is fatal; treating it like an absent pointer
// could overwrite evidence of a partial activation and migrate stale files.
currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
if _, statErr := os.Lstat(currentPath); statErr == nil {
ptr, err := installlayout.ReadCurrent(installRoot)
if err != nil {
return err
}
if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
return err
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
_ = startLauncher(installRoot) // best-effort; layout already committed
selfDelete()
}
return nil
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("migrate: inspect current.json: %w", statErr)
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
helperName := installlayout.UpdateHelperBinaryName()
flatDesktop := filepath.Join(installRoot, desktopName)
if _, err := os.Lstat(flatDesktop); err != nil {
return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
}
members := []installlayout.Member{
{Name: desktopName, Path: flatDesktop},
}
if p := optionalRegular(filepath.Join(installRoot, cliName)); p != "" {
members = append(members, installlayout.Member{Name: cliName, Path: p})
} else {
// CLI may be absent on some portable trees; synthesize from desktop only
// is not allowed — require the whitelist. Prefer copying desktop as a
// last-resort placeholder is forbidden; fail closed.
return fmt.Errorf("migrate: flat CLI binary %s is required", cliName)
}
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, helperName)
if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
members = append(members, installlayout.Member{Name: helperName, Path: p})
} else {
return fmt.Errorf("migrate: flat update helper %s is required", helperName)
}
}
// Old Linux updaters only publish desktop, CLI, and the compatibility
// migrator. On Unix the migrator is also a thin-launcher multicall binary,
// so it can create the permanent entry point without another payload member.
if err := ensureLauncherEntry(installRoot); err != nil {
return err
}
// v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
// The flat release unit is still intact here, so commit that exact verified
// transaction before moving its files into the version directory.
if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
return err
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "legacy-migrate",
Members: members,
RequiredNames: requiredNames,
}); err != nil {
return fmt.Errorf("migrate: activate versioned layout: %w", err)
}
_ = cleanupLegacyFlatFiles(installRoot)
_ = archiveInstallRootLegacyMarkers(installRoot)
if relaunch {
// Launcher start is best-effort: layout activation is the commit point.
if err := startLauncher(installRoot); err != nil {
fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
}
// Unix can unlink the running migrator. On Windows the parent thin launcher
// removes it after this process exits.
selfDelete()
}
return nil
}
func normalizeActiveVersion(activeVersion string) (string, error) {
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
// Accept bare "dev" builds in development only.
if activeVersion == "" || activeVersion == "dev" {
activeVersion = "v0.0.0-dev"
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else if !strings.HasPrefix(activeVersion, "v") {
activeVersion = "v" + activeVersion
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
return "", err
}
} else {
return "", err
}
}
return activeVersion, nil
}
func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
return fmt.Errorf("installer staging must be inside the install root")
}
info, err := os.Lstat(stagingRoot)
if err != nil {
return fmt.Errorf("inspect installer staging: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("installer staging must be a real directory")
}
desktopName := installlayout.DesktopBinaryName()
cliName := installlayout.CLIBinaryName()
requiredNames := []string{desktopName, cliName}
if runtime.GOOS == "windows" {
requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
}
members := make([]installlayout.Member, 0, len(requiredNames))
for _, name := range requiredNames {
members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
}
launcherName := installlayout.LauncherBinaryName()
launcherSource := filepath.Join(stagingRoot, launcherName)
rootMembers := []installlayout.Member{
{Name: launcherName, Path: launcherSource},
{Name: cliName, Path: filepath.Join(stagingRoot, cliName)},
}
requiredRootNames := []string{launcherName, cliName}
if alias := installlayout.PortableAliasName(); alias != "" {
rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
requiredRootNames = append(requiredRootNames, alias)
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: activeVersion,
RequestID: "signed-installer-" + activeVersion,
Members: members,
RequiredNames: requiredNames,
RootMembers: rootMembers,
RequiredRootNames: requiredRootNames,
}); err != nil {
return fmt.Errorf("activate signed installer staging: %w", err)
}
return nil
}
func runningAsLauncher() bool {
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
}
func optionalRegular(path string) string {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ""
}
return path
}
func acquireMigrationLock(installRoot string) (func(), error) {
if err := os.MkdirAll(installRoot, 0o755); err != nil {
return nil, err
}
path := filepath.Join(installRoot, migrationLockName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if os.IsExist(err) {
// Stale lock from a dead migrator: if older than 10 minutes, steal it.
info, statErr := os.Stat(path)
if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
_ = os.Remove(path)
f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
}
}
if err != nil {
return nil, fmt.Errorf("migrate: acquire lock: %w", err)
}
}
_, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
_ = f.Close()
return func() { _ = os.Remove(path) }, nil
}
func ensureLauncherEntry(installRoot string) error {
launcher := installlayout.LauncherBinaryName()
path := filepath.Join(installRoot, launcher)
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
}
return nil
}
// On Windows also accept Reasonix.exe as the alias.
if runtime.GOOS == "windows" {
if _, err := os.Lstat(filepath.Join(installRoot, "Reasonix.exe")); err == nil {
return nil
}
return fmt.Errorf("migrate: thin launcher %s is missing; install a signed package", launcher)
}
// Legacy Linux archives cannot deliver a fourth member through the old
// updater. Copy this signed multicall migrator as the permanent thin launcher.
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("migrate: resolve migrator executable: %w", err)
}
info, err := os.Lstat(exe)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("migrate: migrator executable is not a regular file")
}
body, err := os.ReadFile(exe)
if err != nil {
return fmt.Errorf("migrate: read launcher source: %w", err)
}
if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
return fmt.Errorf("migrate: install thin launcher: %w", err)
}
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("migrate: read legacy pending update: %w", err)
}
if tx.TargetKind != "file" {
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
}
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
}
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
return fmt.Errorf("migrate: pending update target escapes install root")
}
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
}
if current, err := repair.ReadPendingUpdate(); err == nil {
if repair.UpdateTransactionID(current) == id {
return fmt.Errorf("migrate: legacy pending update remained after commit")
}
return fmt.Errorf("migrate: pending update changed during commit")
} else if !os.IsNotExist(err) {
return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
}
return nil
}
func pathWithinInstallRoot(installRoot, target string) bool {
root := filepath.Clean(strings.TrimSpace(installRoot))
target = filepath.Clean(strings.TrimSpace(target))
if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
return false
}
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
return false
}
return true
}
func cleanupLegacyFlatFiles(installRoot string) error {
// Remove flat desktop/CLI/helper/guard sidecars after the version tree is
// active. Never delete the thin launcher or current.json.
names := []string{
installlayout.DesktopBinaryName(),
installlayout.CLIBinaryName(),
installlayout.UpdateHelperBinaryName(),
}
if runtime.GOOS == "windows" {
names = append(names, "reasonix-guard.exe")
} else {
names = append(names, "reasonix-guard")
}
for _, name := range names {
path := filepath.Join(installRoot, name)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
_ = os.Remove(path)
}
return nil
}
func archiveInstallRootLegacyMarkers(installRoot string) error {
// Very old portable builds wrote markers beside the binary. Current repair
// state under Reasonix home is finalized through repair transaction APIs.
markers := []string{
"pending-update.json",
"startup-state.json",
}
ts := time.Now().UTC().Format("20060102T150405Z")
destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
moved := false
for _, name := range markers {
src := filepath.Join(installRoot, name)
if _, err := os.Lstat(src); err != nil {
continue
}
if err := os.MkdirAll(destRoot, 0o755); err != nil {
return err
}
dest := filepath.Join(destRoot, name)
if err := os.Rename(src, dest); err != nil {
// Cross-device: copy+remove best effort.
data, readErr := os.ReadFile(src)
if readErr != nil {
continue
}
if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
_ = os.Remove(src)
}
}
moved = true
}
if moved {
meta, _ := json.MarshalIndent(map[string]any{
"migratedAt": time.Now().UTC().Format(time.RFC3339),
"from": "flat-v1",
"to": installlayout.InstallLayoutVersionedV1,
}, "", " ")
_ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
}
return nil
}
func startLauncher(installRoot string) error {
launcher := "reasonix-launcher"
if runtime.GOOS == "windows" {
launcher += ".exe"
}
path := filepath.Join(installRoot, launcher)
if _, err := os.Lstat(path); err != nil {
if runtime.GOOS == "windows" {
path = filepath.Join(installRoot, "Reasonix.exe")
}
}
if _, err := os.Lstat(path); err != nil {
// Migration succeeded; user can start manually.
return nil
}
cmd := exec.Command(path)
cmd.Dir = installRoot
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if runtime.GOOS == "windows" {
return cmd.Start()
}
return cmd.Start()
}
func selfDelete() {
exe, err := os.Executable()
if err != nil {
return
}
// Do not delete if we are not named like a compatibility guard payload.
base := strings.ToLower(filepath.Base(exe))
if base != "reasonix-guard" && base != "reasonix-guard.exe" {
return
}
_ = os.Remove(exe)
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/installlayout"
)
func installerVersionNames() []string {
names := []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()}
if runtime.GOOS == "windows" {
names = append(names, installlayout.UpdateHelperBinaryName())
}
return names
}
func writeInstallerStaging(t *testing.T, root, label string, includeLauncher bool) string {
t.Helper()
staging := filepath.Join(root, "versions", ".installer-"+label)
if err := os.MkdirAll(staging, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range installerVersionNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if includeLauncher {
name := installlayout.LauncherBinaryName()
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
return staging
}
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
// Flat release unit.
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("flat-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
// Thin launcher entry must already exist (packaging places it). Use a
// non-executable marker so startLauncher fails closed without hanging.
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher.exe"), []byte("launcher"), 0o644)
_ = os.WriteFile(filepath.Join(root, "Reasonix.exe"), []byte("launcher"), 0o644)
// Marker files that must be archived, not deleted silently without record.
_ = os.WriteFile(filepath.Join(root, "pending-update.json"), []byte(`{"pending":true}`), 0o644)
_ = os.WriteFile(filepath.Join(root, "startup-state.json"), []byte(`{}`), 0o644)
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(root) {
t.Fatal("current.json missing after migration")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
// Flat desktop must be cleaned up after successful activation.
if _, err := os.Stat(filepath.Join(root, installlayout.DesktopBinaryName())); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed after migration")
}
// Active desktop lives under versions/.
if _, err := installlayout.ActiveDesktopPath(root); err != nil {
t.Fatal(err)
}
// Second run is idempotent cleanup-only.
if err := migrate(root, "v1.20.0"); err != nil {
t.Fatalf("idempotent re-run: %v", err)
}
ptr2, err := installlayout.ReadCurrent(root)
if err != nil || ptr2.ActiveVersion != "v1.20.0" {
t.Fatalf("re-run overwrote active version: %+v", ptr2)
}
}
func TestMigrateRefusesWithoutFlatUnit(t *testing.T) {
root := t.TempDir()
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("expected failure without flat desktop")
}
}
func TestMigrateRefusesCorruptCurrentPointerWithoutOverwritingIt(t *testing.T) {
root := t.TempDir()
corrupt := []byte(`{"schemaVersion":99}`)
current := filepath.Join(root, installlayout.CurrentFileName)
if err := os.WriteFile(current, corrupt, 0o644); err != nil {
t.Fatal(err)
}
for _, name := range installlayout.AllowedVersionMembers() {
if err := os.WriteFile(filepath.Join(root, name), []byte("stale-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := migrate(root, "v1.20.0"); err == nil {
t.Fatal("corrupt current.json was treated as an absent pointer")
}
got, err := os.ReadFile(current)
if err != nil {
t.Fatal(err)
}
if string(got) != string(corrupt) {
t.Fatalf("corrupt pointer was overwritten: %q", got)
}
}
func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
root := t.TempDir()
staging := writeInstallerStaging(t, root, "new", true)
if err := activateInstallerStaging(root, "v1.20.0", staging); err != nil {
t.Fatal(err)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
activeDesktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(activeDesktop); err != nil || string(got) != "new-"+installlayout.DesktopBinaryName() {
t.Fatalf("active desktop=%q err=%v", got, err)
}
for _, name := range []string{installlayout.LauncherBinaryName(), installlayout.CLIBinaryName()} {
if info, err := os.Lstat(filepath.Join(root, name)); err != nil || !info.Mode().IsRegular() {
t.Fatalf("root entry %s missing or invalid: %v", name, err)
}
}
if alias := installlayout.PortableAliasName(); alias != "" {
if got, err := os.ReadFile(filepath.Join(root, alias)); err != nil || string(got) != "new-"+installlayout.LauncherBinaryName() {
t.Fatalf("portable alias=%q err=%v", got, err)
}
}
}
func TestActivateInstallerStagingFailureKeepsPreviousPointer(t *testing.T) {
root := t.TempDir()
oldStaging := writeInstallerStaging(t, root, "old", true)
if err := activateInstallerStaging(root, "v1.19.1", oldStaging); err != nil {
t.Fatal(err)
}
broken := writeInstallerStaging(t, root, "broken", false)
if err := activateInstallerStaging(root, "v1.20.0", broken); err == nil {
t.Fatal("staging without a launcher was activated")
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != "v1.19.1" {
t.Fatalf("previous pointer changed after failed activation: %+v err=%v", ptr, err)
}
if err := activateInstallerStaging(root, "v1.20.0", t.TempDir()); err == nil {
t.Fatal("staging outside the install root was accepted")
}
}
+17 -1
View File
@@ -3,18 +3,34 @@ package main
import (
"os"
"runtime/debug"
"reasonix/internal/cli"
"reasonix/internal/config"
"reasonix/internal/crashreport"
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
_ "reasonix/internal/tool/builtin"
)
// version is injected at build time via -ldflags "-X main.version=...".
var version = "dev"
var runCLI = cli.Run
func main() {
os.Exit(cli.Run(os.Args[1:], version))
os.Exit(runWithCrashCapture(os.Args[1:], version))
}
func runWithCrashCapture(args []string, buildVersion string) (exitCode int) {
defer func() {
if recovered := recover(); recovered != nil {
_ = crashreport.CapturePanic(config.ReasonixHomeDir(), buildVersion, recovered, debug.Stack())
panic(recovered)
}
}()
return runCLI(args, buildVersion)
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/crashreport"
)
func TestRunWithCrashCaptureRecordsAndReraises(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
previous := runCLI
t.Cleanup(func() { runCLI = previous })
secret := "private prompt from panic"
runCLI = func([]string, string) int { panic(secret) }
func() {
defer func() {
if recovered := recover(); recovered != secret {
t.Fatalf("reraised panic = %#v", recovered)
}
}()
runWithCrashCapture([]string{"run"}, "v1.20.0")
}()
reports, err := crashreport.List(config.ReasonixHomeDir())
if err != nil || len(reports) != 1 {
t.Fatalf("captured reports=%d err=%v", len(reports), err)
}
if got := reports[0].Report.TopFrame; !strings.Contains(got, "main_test.go:") {
t.Fatalf("top frame = %q, want panic call site in main_test.go", got)
}
preview, err := crashreport.Preview(reports[0].Report)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(preview), secret) {
t.Fatalf("panic value leaked into report: %s", preview)
}
}
func TestRunWithCrashCapturePassesThroughExitCode(t *testing.T) {
previous := runCLI
t.Cleanup(func() { runCLI = previous })
runCLI = func(args []string, version string) int {
if len(args) != 1 || args[0] != "version" || version != "v1.20.0" {
t.Fatalf("runCLI args=%v version=%q", args, version)
}
return 17
}
if got := runWithCrashCapture([]string{"version"}, "v1.20.0"); got != 17 {
t.Fatalf("exit code=%d", got)
}
}
+389
View File
@@ -0,0 +1,389 @@
package main
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
const (
contractPath = ".signpath/contracts/release-signing.yml"
projectSlug = "DeepSeek-Reasonix"
policySlug = "release-signing"
repository = "https://github.com/esengine/DeepSeek-Reasonix.git"
)
var expectedBranches = []string{"main-v2"}
type releaseSigningContract struct {
Version int `yaml:"version"`
ProjectSlug string `yaml:"project_slug"`
SigningPolicySlug string `yaml:"signing_policy_slug"`
RepositoryURL string `yaml:"repository_url"`
AllowedBranchNames []string `yaml:"allowed_branch_names"`
AllowedBuildDefinitions []string `yaml:"allowed_build_definitions"`
FingerprintFiles []string `yaml:"fingerprint_files"`
}
type workflowInfo struct {
externallyTriggered bool
directSigning bool
calls []string
}
func main() {
if len(os.Args) < 2 || len(os.Args) > 3 {
fatalf("usage: signpath-contract <validate|fingerprint> [repository-root]")
}
root := "."
if len(os.Args) == 3 {
root = os.Args[2]
}
contract, err := loadAndValidate(root)
if err != nil {
fatalf("%v", err)
}
switch os.Args[1] {
case "validate":
fmt.Println("SignPath release signing contract is valid")
case "fingerprint":
fingerprint, err := contractFingerprint(root, contract)
if err != nil {
fatalf("fingerprint contract: %v", err)
}
fmt.Printf("v1:%x\n", fingerprint)
default:
fatalf("unknown command %q", os.Args[1])
}
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "signpath-contract: "+format+"\n", args...)
os.Exit(1)
}
func loadAndValidate(root string) (releaseSigningContract, error) {
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(contractPath)))
if err != nil {
return releaseSigningContract{}, fmt.Errorf("read %s: %w", contractPath, err)
}
var contract releaseSigningContract
if err := yaml.Unmarshal(data, &contract); err != nil {
return releaseSigningContract{}, fmt.Errorf("parse %s: %w", contractPath, err)
}
if err := validateContract(root, contract); err != nil {
return releaseSigningContract{}, err
}
return contract, nil
}
func validateContract(root string, contract releaseSigningContract) error {
if contract.Version != 1 {
return fmt.Errorf("%s version is %d, want 1", contractPath, contract.Version)
}
if contract.ProjectSlug != projectSlug {
return fmt.Errorf("project_slug is %q, want %q", contract.ProjectSlug, projectSlug)
}
if contract.SigningPolicySlug != policySlug {
return fmt.Errorf("signing_policy_slug is %q, want %q", contract.SigningPolicySlug, policySlug)
}
if contract.RepositoryURL != repository {
return fmt.Errorf("repository_url is %q, want %q", contract.RepositoryURL, repository)
}
if err := requireExactSet("allowed_branch_names", contract.AllowedBranchNames, expectedBranches); err != nil {
return err
}
if len(contract.AllowedBuildDefinitions) == 0 {
return errors.New("allowed_build_definitions must not be empty")
}
for _, value := range append(append([]string{}, contract.AllowedBranchNames...), contract.AllowedBuildDefinitions...) {
if strings.ContainsAny(value, "*?[") {
return fmt.Errorf("SignPath allowlists must not contain wildcard %q", value)
}
}
for _, name := range contract.AllowedBuildDefinitions {
if err := validateRepositoryPath(name); err != nil {
return fmt.Errorf("invalid allowed build definition %q: %w", name, err)
}
if !strings.HasPrefix(name, ".github/workflows/") {
return fmt.Errorf("allowed build definition %q is outside .github/workflows", name)
}
}
if err := requireUnique("allowed_build_definitions", contract.AllowedBuildDefinitions); err != nil {
return err
}
if len(contract.FingerprintFiles) == 0 {
return errors.New("fingerprint_files must not be empty")
}
seen := make(map[string]bool, len(contract.FingerprintFiles))
for _, name := range contract.FingerprintFiles {
if err := validateRepositoryPath(name); err != nil {
return fmt.Errorf("invalid fingerprint file %q: %w", name, err)
}
if seen[name] {
return fmt.Errorf("duplicate fingerprint file %q", name)
}
seen[name] = true
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
return fmt.Errorf("fingerprint file %q: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("fingerprint file %q is not a regular file", name)
}
}
reachable, err := discoverTopLevelSigningWorkflows(root)
if err != nil {
return err
}
if err := requireExactSet("top-level workflows that reach SignPath", reachable, contract.AllowedBuildDefinitions); err != nil {
return fmt.Errorf("SignPath build-definition drift: %w", err)
}
return nil
}
func requireExactSet(name string, got, want []string) error {
gotCopy := append([]string(nil), got...)
wantCopy := append([]string(nil), want...)
sort.Strings(gotCopy)
sort.Strings(wantCopy)
if len(gotCopy) != len(wantCopy) {
return fmt.Errorf("%s is %v, want %v", name, got, want)
}
for i := range gotCopy {
if gotCopy[i] != wantCopy[i] {
return fmt.Errorf("%s is %v, want %v", name, got, want)
}
if i > 0 && gotCopy[i] == gotCopy[i-1] {
return fmt.Errorf("%s contains duplicate %q", name, gotCopy[i])
}
}
return nil
}
func requireUnique(name string, values []string) error {
seen := make(map[string]bool, len(values))
for _, value := range values {
if seen[value] {
return fmt.Errorf("%s contains duplicate %q", name, value)
}
seen[value] = true
}
return nil
}
func validateRepositoryPath(name string) error {
if name == "" || filepath.IsAbs(filepath.FromSlash(name)) {
return errors.New("path must be non-empty and repository-relative")
}
clean := filepath.Clean(filepath.FromSlash(name))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return errors.New("path escapes the repository")
}
if filepath.ToSlash(clean) != name {
return errors.New("path must be normalized with forward slashes")
}
return nil
}
func discoverTopLevelSigningWorkflows(root string) ([]string, error) {
paths, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.y*ml"))
if err != nil {
return nil, fmt.Errorf("list workflows: %w", err)
}
workflows := make(map[string]workflowInfo, len(paths))
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read workflow %s: %w", path, err)
}
name, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
name = filepath.ToSlash(name)
info, err := parseWorkflow(data)
if err != nil {
return nil, fmt.Errorf("parse workflow %s: %w", name, err)
}
workflows[name] = info
}
state := make(map[string]uint8, len(workflows))
memo := make(map[string]bool, len(workflows))
var reachesSigning func(string) (bool, error)
reachesSigning = func(name string) (bool, error) {
if state[name] == 2 {
return memo[name], nil
}
if state[name] == 1 {
return false, fmt.Errorf("reusable workflow cycle includes %s", name)
}
info, ok := workflows[name]
if !ok {
return false, fmt.Errorf("workflow calls missing local workflow %s", name)
}
state[name] = 1
reachable := info.directSigning
for _, called := range info.calls {
calledReachable, err := reachesSigning(called)
if err != nil {
return false, err
}
reachable = reachable || calledReachable
}
state[name] = 2
memo[name] = reachable
return reachable, nil
}
var result []string
for name, info := range workflows {
if !info.externallyTriggered {
continue
}
reachable, err := reachesSigning(name)
if err != nil {
return nil, err
}
if reachable {
result = append(result, name)
}
}
sort.Strings(result)
return result, nil
}
func parseWorkflow(data []byte) (workflowInfo, error) {
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
return workflowInfo{}, err
}
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
return workflowInfo{}, errors.New("workflow root must be a mapping")
}
root := document.Content[0]
on := mappingValue(root, "on")
jobs := mappingValue(root, "jobs")
if on == nil || jobs == nil || jobs.Kind != yaml.MappingNode {
return workflowInfo{}, errors.New("workflow must contain on and jobs mappings")
}
info := workflowInfo{externallyTriggered: hasExternalTrigger(on)}
for i := 1; i < len(jobs.Content); i += 2 {
job := jobs.Content[i]
if job.Kind != yaml.MappingNode {
continue
}
if uses := mappingScalar(job, "uses"); strings.HasPrefix(uses, "./.github/workflows/") {
info.calls = append(info.calls, strings.TrimPrefix(uses, "./"))
}
if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") {
info.directSigning = true
}
steps := mappingValue(job, "steps")
if steps == nil || steps.Kind != yaml.SequenceNode {
continue
}
for _, step := range steps.Content {
if step.Kind != yaml.MappingNode {
continue
}
if strings.HasPrefix(mappingScalar(step, "uses"), "signpath/github-action-submit-signing-request@") {
info.directSigning = true
}
}
}
sort.Strings(info.calls)
return info, nil
}
func nodeContains(node *yaml.Node, text string) bool {
if node == nil {
return false
}
if node.Kind == yaml.ScalarNode && strings.Contains(node.Value, text) {
return true
}
for _, child := range node.Content {
if nodeContains(child, text) {
return true
}
}
return false
}
func hasExternalTrigger(node *yaml.Node) bool {
switch node.Kind {
case yaml.ScalarNode:
return node.Value != "workflow_call"
case yaml.SequenceNode:
for _, child := range node.Content {
if child.Value != "workflow_call" {
return true
}
}
case yaml.MappingNode:
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value != "workflow_call" {
return true
}
}
}
return false
}
func mappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == key {
return node.Content[i+1]
}
}
return nil
}
func mappingScalar(node *yaml.Node, key string) string {
value := mappingValue(node, key)
if value == nil || value.Kind != yaml.ScalarNode {
return ""
}
return value.Value
}
func contractFingerprint(root string, contract releaseSigningContract) ([sha256.Size]byte, error) {
names := append([]string{contractPath}, contract.FingerprintFiles...)
sort.Strings(names)
hash := sha256.New()
_, _ = io.WriteString(hash, "reasonix-signpath-release-contract-v1\x00")
for _, name := range names {
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
return [sha256.Size]byte{}, fmt.Errorf("read %s: %w", name, err)
}
writeLengthPrefixed(hash, []byte(name))
writeLengthPrefixed(hash, data)
}
var result [sha256.Size]byte
copy(result[:], hash.Sum(nil))
return result, nil
}
func writeLengthPrefixed(writer io.Writer, data []byte) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(data)))
_, _ = writer.Write(length[:])
_, _ = writer.Write(data)
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"fmt"
"reflect"
"testing"
)
func TestRepositoryReleaseSigningContract(t *testing.T) {
root := "../.."
contract, err := loadAndValidate(root)
if err != nil {
t.Fatal(err)
}
fingerprint, err := contractFingerprint(root, contract)
if err != nil {
t.Fatal(err)
}
if got := fmt.Sprintf("%x", fingerprint); len(got) != 64 {
t.Fatalf("fingerprint length = %d, want 64", len(got))
}
}
func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
got, err := discoverTopLevelSigningWorkflows("../..")
if err != nil {
t.Fatal(err)
}
want := []string{
".github/workflows/release-desktop.yml",
".github/workflows/release-stable.yml",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("top-level workflows that reach SignPath = %v, want %v", got, want)
}
}
func TestReleaseSigningContractRejectsWildcards(t *testing.T) {
contract, err := loadAndValidate("../..")
if err != nil {
t.Fatal(err)
}
contract.AllowedBuildDefinitions = []string{".github/workflows/release-*.yml"}
if err := validateContract("../..", contract); err == nil {
t.Fatal("wildcard build definition unexpectedly passed validation")
}
}
func TestWorkflowUsingSignPathTokenIsSigningEntryPoint(t *testing.T) {
workflow := []byte(`
on: workflow_dispatch
jobs:
sign:
runs-on: windows-latest
steps:
- shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
run: ./submit-signing-request.ps1
`)
info, err := parseWorkflow(workflow)
if err != nil {
t.Fatal(err)
}
if !info.externallyTriggered || !info.directSigning {
t.Fatalf("token-backed workflow was not classified as a signing entry point: %+v", info)
}
}
+2
View File
@@ -20,6 +20,7 @@
/build/darwin/*
!/build/darwin/entitlements.plist
!/build/darwin/icon.icns
!/build/darwin/appicon.svg
# Commit the hand-written Linux .deb packaging inputs and app icon assets.
# `wails build` does not generate these, so they must live in git. The .deb is built
@@ -28,6 +29,7 @@
/build/linux/*
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/io.reasonix.desktop.update.policy
!/build/linux/icons
!/build/linux/icons/**
+23 -9
View File
@@ -137,9 +137,19 @@ The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
not depend on homepage badge semantics. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
installer (no admin rights needed).
- **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace
the binaries in the install directory, and relaunch through Guard. No elevation.
- **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator
authorization via Polkit (`pkexec`), re-verify and install with `apt-get
--only-upgrade`, then relaunch through Guard. The first build that ships the
update helper and Polkit policy is a one-time bootstrap: existing `.deb` users
should overwrite-install once with
`sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After
that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use
the same manual command. Failed installs leave the running app intact so you
can retry; successful installs are managed by apt/dpkg and are not auto-downgraded.
- **Windows** — download, verify the minisign signature, then run the per-user
NSIS installer (no admin rights needed).
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
in-place swap would be blocked by Gatekeeper; the banner links to the download
page for a manual update instead.
@@ -272,16 +282,20 @@ desktop/
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
a random install id (generated locally, tied to nothing), app version, OS,
arch, and OS version. It exists solely to count active installs. It never
includes conversations, API keys, file contents, or paths.
arch, and OS version. When the previous process ended abnormally, the next
normal launch may also send a bounded native diagnostic (lifecycle phase,
symbolized stack, WebView2/window failure kind, and coarse device facts).
Panic values are removed and paths/secrets are scrubbed before the report is
queued. It never includes conversations, API keys, or file contents.
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
`telemetry = false` under `[desktop]` in the global config. Dev builds
never ping. Crash and performance-pressure reports are separate and only
ever sent when the user clicks "Send report" on the diagnostic UI.
never ping or upload queued native diagnostics. Frontend crash and
performance-pressure reports remain separate and are sent only when the user
clicks "Send report" on the diagnostic UI.
Aggregate quality metrics are also enabled by default and can be disabled from
Settings > Updates > "Share aggregate quality metrics", or by setting
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
counts and preference buckets; they never include conversations, prompts, keys,
paths, base URLs, or file contents.
counts, lifecycle/window failure buckets, and preference buckets; they never
include conversations, prompts, keys, paths, base URLs, or file contents.
+1618 -417
View File
File diff suppressed because it is too large. Load diff
+444 -81
View File
@@ -28,6 +28,7 @@ import (
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/instruction"
"reasonix/internal/jobs"
"reasonix/internal/mcplaunch"
"reasonix/internal/memory"
@@ -473,7 +474,9 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
}
deadline := time.Now().Add(time.Second)
// The first git process launch can be noticeably slower on Windows runners
// while MetaForTab intentionally keeps the caller path non-blocking.
deadline := time.Now().Add(5 * time.Second)
for {
if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
break
@@ -596,18 +599,78 @@ func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
isolateDesktopUserDirs(t)
view := NewApp().Memory()
if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {
if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil || view.InstructionDiagnostics == nil || view.Conflicts == nil || view.LastRecall.Hits == nil {
t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
}
raw, err := json.Marshal(view)
if err != nil {
t.Fatalf("marshal Memory(): %v", err)
}
for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} {
for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`, `"instructionDiagnostics":null`, `"conflicts":null`, `"hits":null`} {
if strings.Contains(string(raw), bad) {
t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
}
}
if revisions := NewApp().MemoryRevisions("missing"); revisions == nil {
t.Fatal("MemoryRevisions must return [] before startup, not nil")
}
}
func TestMemoryViewIncludesRecallFreshnessAndOverrides(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
store := memory.Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
Name: "deploy-target", Title: "Deploy target", Description: "legacy deployment target", Scope: memory.FactScopeGlobal, Type: memory.TypeProject, Body: "Deploy payments to the legacy cluster.",
}); err != nil {
t.Fatal(err)
}
if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
Name: "deploy-target", Title: "Deploy target", Description: "current deployment target", Scope: memory.FactScopeProject, Type: memory.TypeProject, Body: "Deploy payments to the green cluster.",
}); err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{Memory: &memory.Set{Store: store}})
ctrl.Compose("deploy payments target cluster")
app := NewApp()
app.setTestCtrl(ctrl, "test-model")
view := app.Memory()
if len(view.Facts) != 2 || view.Facts[0].Freshness == "" || view.Facts[1].Freshness == "" {
t.Fatalf("facts with freshness = %+v", view.Facts)
}
if len(view.Conflicts) != 1 || view.Conflicts[0].Resolution != "project_over_global" {
t.Fatalf("conflicts = %+v", view.Conflicts)
}
if view.LastRecall.Query != "deploy payments target cluster" || len(view.LastRecall.Hits) != 1 || view.LastRecall.Hits[0].Scope != "project" {
t.Fatalf("last recall = %+v", view.LastRecall)
}
}
func TestMemoryRevisionAPIRestoresSelectedRevision(t *testing.T) {
isolateDesktopUserDirs(t)
store := memory.Store{Dir: t.TempDir()}
first, err := store.SaveWithOptions(memory.Memory{Name: "fact", Description: "one", Body: "v1"}, memory.SaveOptions{})
if err != nil {
t.Fatal(err)
}
if _, err := store.SaveWithOptions(memory.Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, memory.SaveOptions{}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store}}), "test-model")
revisions := app.MemoryRevisions(first.Memory.ID)
if len(revisions) != 1 || revisions[0].Revision != 1 {
t.Fatalf("revisions = %+v", revisions)
}
restored, err := app.RestoreMemoryRevision(first.Memory.ID, 1)
if err != nil {
t.Fatal(err)
}
if restored.Revision != 3 || restored.Body != "v1" {
t.Fatalf("restored = %+v", restored)
}
}
func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
@@ -638,10 +701,12 @@ func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
app := NewApp()
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
Docs: []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}},
Store: store,
CWD: cwd,
UserDir: userDir,
Docs: []memory.Source{{
Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Directory: cwd,
Body: "Project instructions", Imports: []instruction.Import{{Path: filepath.Join(cwd, "shared.md"), SourcePath: filepath.Join(cwd, "AGENTS.md")}},
}},
InstructionDiagnostics: []instruction.Diagnostic{{Code: "import_cycle", Path: "shared.md", SourcePath: filepath.Join(cwd, "AGENTS.md"), Line: 3, Message: "cycle"}},
Store: store, CWD: cwd, UserDir: userDir,
}}), "test-model")
view := app.Memory()
@@ -651,18 +716,57 @@ func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
t.Fatalf("Memory() docs = %+v", view.Docs)
}
if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" {
if view.Docs[0].Directory != cwd || len(view.Docs[0].Imports) != 1 || len(view.InstructionDiagnostics) != 1 || view.InstructionDiagnostics[0].Code != "import_cycle" {
t.Fatalf("Memory() instruction provenance = docs %+v diagnostics %+v", view.Docs, view.InstructionDiagnostics)
}
if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" || view.Facts[0].Scope != "project" {
t.Fatalf("Memory() active facts = %+v", view.Facts)
}
if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" ||
if view.Facts[0].ID == "" || view.Facts[0].Revision != 1 || view.Facts[0].CreatedAt == "" || view.Facts[0].UpdatedAt == "" {
t.Fatalf("Memory() active fact metadata = %+v", view.Facts[0])
}
if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || view.Archives[0].Scope != "project" ||
view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
t.Fatalf("Memory() archived facts = %+v", view.Archives)
}
if view.Archives[0].ID == "" || view.Archives[0].Revision != 1 || view.Archives[0].CreatedAt == "" || view.Archives[0].UpdatedAt == "" {
t.Fatalf("Memory() archived fact metadata = %+v", view.Archives[0])
}
if len(view.Scopes) != 3 {
t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
}
}
func TestRestoreArchivedMemoryRecoversFactForCurrentSession(t *testing.T) {
isolateDesktopUserDirs(t)
userDir := t.TempDir()
cwd := t.TempDir()
store := memory.StoreFor(userDir, cwd)
first, err := store.SaveWithOptions(memory.Memory{
Name: "restorable-fact", Description: "recover me", Body: "Recovered guidance.",
}, memory.SaveOptions{})
if err != nil {
t.Fatal(err)
}
archivePath, err := store.Archive(first.Memory.ID)
if err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}}), "test-model")
if _, err := app.RestoreArchivedMemory(archivePath); err != nil {
t.Fatal(err)
}
view := app.Memory()
if len(view.Facts) != 1 || view.Facts[0].ID != first.Memory.ID || view.Facts[0].Revision != 2 {
t.Fatalf("restored memory view = %+v", view)
}
if len(view.Archives) != 0 {
t.Fatalf("restored archive remained visible: %+v", view.Archives)
}
}
func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
isolateDesktopUserDirs(t)
consumeSystemQuitRequested()
@@ -942,6 +1046,9 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
t.Fatalf("set desktop check updates: %v", err)
}
if err := userCfg.SetDesktopUpdateChannel("preview"); err != nil {
t.Fatalf("set desktop update channel: %v", err)
}
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
@@ -950,7 +1057,7 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
}
got := NewApp().DesktopStartupSettings()
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates {
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "stable" {
t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
}
if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -1583,7 +1690,7 @@ func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
}
}
func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
func TestSettingsPreservesStepFunRegionalPresetBaseURLs(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
@@ -1608,25 +1715,25 @@ func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
view := NewApp().Settings()
for _, id := range []string{"stepfun", "stepfun-anthropic"} {
presetView := providerPresetViewByID(t, view, id)
if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView)
if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified {
t.Fatalf("%s preset view = %+v, want installed-modified for a preserved regional endpoint", id, presetView)
}
}
migrated := config.LoadForEdit(config.UserConfigPath())
stepfunEntryView, ok := migrated.Provider("stepfun")
loaded := config.LoadForEdit(config.UserConfigPath())
stepfunEntryView, ok := loaded.Provider("stepfun")
if !ok {
t.Fatal("stepfun provider missing after migration")
t.Fatal("stepfun provider missing after load")
}
if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" {
t.Fatalf("stepfun base_url = %q, want official URL", got)
if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan/v1" {
t.Fatalf("stepfun base_url = %q, want preserved regional URL", got)
}
stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic")
stepfunAnthropicEntryView, ok := loaded.Provider("stepfun-anthropic")
if !ok {
t.Fatal("stepfun-anthropic provider missing after migration")
t.Fatal("stepfun-anthropic provider missing after load")
}
if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" {
t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got)
if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.ai/step_plan" {
t.Fatalf("stepfun-anthropic base_url = %q, want preserved regional URL", got)
}
}
@@ -2844,6 +2951,21 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
}
}
func TestModelsForTabListsNothingWhenProviderAccessExplicitlyEmpty(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
cfg := config.Default()
cfg.Desktop.ProviderAccess = []string{}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
if models := NewApp().Models(); len(models) != 0 {
t.Fatalf("Models() = %+v, want no models when provider access is explicitly empty", models)
}
}
func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
@@ -3045,7 +3167,7 @@ func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
}
}
func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
func TestSetModelForTabRefreshesCarriedSystemPromptWithoutChangingDefaults(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
@@ -3089,9 +3211,18 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
sink: &tabEventSink{tabID: "tab_a", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
sibling := &WorkspaceTab{
ID: "tab_b",
Scope: "global",
Ready: true,
model: "old/old-model",
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab, sibling.ID: sibling}
app.tabOrder = []string{tab.ID, sibling.ID}
app.activeTabID = tab.ID
var switchTiming modelSwitchTiming
app.modelSwitchTimingHook = func(timing modelSwitchTiming) { switchTiming = timing }
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
@@ -3114,6 +3245,18 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
t.Fatalf("carried user message changed: %+v", history[1])
}
if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "old/old-model" {
t.Fatalf("default model after session switch = %q, want old/old-model", got)
}
if sibling.model != "old/old-model" {
t.Fatalf("sibling tab model after session switch = %q, want old/old-model", sibling.model)
}
if switchTiming.Outcome != "ok" || switchTiming.Total <= 0 {
t.Fatalf("model switch timing = %+v, want successful non-zero observation", switchTiming)
}
if switchTiming.Build <= 0 || switchTiming.LeaseAndResume <= 0 || switchTiming.SwapAndPersist <= 0 {
t.Fatalf("model switch stage timing incomplete: %+v", switchTiming)
}
}
// TestSetModelForTabRestoresSessionAuthorizations pins the fix for a model
@@ -3704,10 +3847,8 @@ func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
// Steers and idle-steer fallbacks start real provider turns (they are no
// longer command-interpreted), so the fixture's provider must complete
// instantly instead of pointing at an unreachable host — otherwise every
// steer leaves the controller running for the rest of the test.
// Submitted turns use a real provider, so the fixture must complete them
// instantly instead of pointing at an unreachable host.
providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
@@ -3916,16 +4057,13 @@ func TestDescribeSessionBindingWorkspaceKeepsWindowsPathReadable(t *testing.T) {
}
}
func TestSteerForTabReconcilesStaleWorkspaceBeforeIdleFallback(t *testing.T) {
func TestSteerForTabReconcilesStaleWorkspaceBeforeRejectingIdleGuidance(t *testing.T) {
f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback")
// The idle fallback submits the steer text verbatim as a provider turn
// (steers are never command-interpreted); the fixture's provider stub
// completes it instantly.
if err := f.app.SteerForTab(f.tab.ID, "steer guidance"); err != nil {
t.Fatalf("SteerForTab: %v", err)
err := f.app.SteerForTab(f.tab.ID, "steer guidance")
if err == nil || !strings.Contains(err.Error(), "remain queued") {
t.Fatalf("SteerForTab error = %v, want explicit rejected-guidance result", err)
}
waitNotRunning(t, f.tab.Ctrl)
assertTabRebuiltToPinnedWorkspace(t, f)
}
@@ -4099,12 +4237,12 @@ func runQuickClickWorkspaceReconcileTest(t *testing.T, layoutStyle string) {
wg.Wait()
close(errs)
for err := range errs {
// The steer action holds a real provider turn now, so racing quick
// clicks may legitimately observe a busy controller. This test
// asserts workspace-rebuild serialization, not that every
// concurrent action wins the turn.
// Racing quick clicks may legitimately observe a busy controller or an
// already-ended steer target. This test asserts workspace-rebuild
// serialization, not that every concurrent action wins admission.
if strings.Contains(err.Error(), "turn already running") ||
strings.Contains(err.Error(), "cannot compact while a turn is running") {
strings.Contains(err.Error(), "cannot compact while a turn is running") ||
strings.Contains(err.Error(), "remain queued") {
continue
}
t.Error(err)
@@ -4870,6 +5008,32 @@ func TestConnectKeyRestoresDeepSeekProviderAccess(t *testing.T) {
}
}
func TestBalanceForTabUsesDesktopPricingCurrency(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.Desktop.Currency = "USD"
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save USD desktop currency: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"70.16"},{"currency":"USD","total_balance":"9.82"}]}`)
}))
defer srv.Close()
app := NewApp()
app.ctx = context.Background()
ctrl := control.New(control.Options{BalanceURL: srv.URL, BalanceClient: srv.Client()})
t.Cleanup(ctrl.Close)
app.setTestCtrl(ctrl, "deepseek/deepseek-v4-flash")
got := app.BalanceForTab("test")
if !got.Available || got.Display != "$9.82" || got.Err != "" {
t.Fatalf("USD desktop balance = %+v, want available $9.82", got)
}
}
func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv(onboardingKeyEnv, "")
@@ -5261,6 +5425,10 @@ func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
if got := currentTabTokenMode(tab); got != "full" {
t.Fatalf("token mode = %q, want full", got)
}
meta := app.MetaForTab(tab.ID)
if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
t.Fatalf("failed switch disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
}
}
func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
@@ -5454,6 +5622,17 @@ func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -5462,12 +5641,17 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
path := filepath.Join(dir, "jobs.jsonl")
jm := jobs.NewManager(event.Discard)
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
defer ctrl.Close()
app := NewApp()
app.setTestCtrl(ctrl, "")
app.ctx = context.Background()
app.setTestCtrl(ctrl, "old/old-model")
t.Cleanup(func() {
if current := app.activeCtrl(); current != nil {
current.Close()
}
})
release := make(chan struct{})
jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
@@ -5475,11 +5659,21 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
return "", nil
}
})
defer close(release)
t.Cleanup(func() { close(release) })
err := app.SetTokenMode("economy")
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
}
cancelled, err := app.CancelJobForTab("", job.ID)
if err != nil || !cancelled {
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
}
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
t.Fatalf("stopped background job = %+v, want one killed result", result)
}
if err := app.SetTokenMode("economy"); err != nil {
t.Fatalf("SetTokenMode after stopping background job: %v", err)
}
}
@@ -6814,6 +7008,46 @@ func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) {
}
}
func TestSubmitEntryPointsRejectEmptyProviderInput(t *testing.T) {
app := NewApp()
for _, tt := range []struct {
name string
call func() error
}{
{name: "plain", call: func() error { return app.SubmitToTab("missing", " \n\t ") }},
{name: "display", call: func() error { return app.SubmitDisplayToTab("missing", "visible prompt", " ") }},
{name: "delivery recovery", call: func() error {
return app.SubmitDeliveryRecoveryToTab("missing", "visible prompt", "")
}},
{name: "invocations", call: func() error {
return app.SubmitInvocationsToTab("missing", "/skill visible", "", nil)
}},
{name: "edited display", call: func() error {
return app.SubmitEditedDisplayToTab("missing", "visible prompt", "\n", "original prompt")
}},
{name: "initial goal", call: func() error {
_, err := app.SubmitInitialGoalToTab("missing", "goal", "visible prompt", "", nil, "normal", "auto", "local", 0, 0)
return err
}},
} {
t.Run(tt.name, func(t *testing.T) {
if err := tt.call(); !errors.Is(err, errEmptyTurnInput) {
t.Fatalf("error = %v, want errEmptyTurnInput", err)
}
})
}
}
func TestInvocationEntryPointsAllowEmptyExplicitTaskForSkillOnlyTurn(t *testing.T) {
invocations := []InvocationRequest{{Name: "skill", Kind: "skill"}}
if err := validateInvocationTurnInput("", invocations); err != nil {
t.Fatalf("skill-only invocation input rejected: %v", err)
}
if err := validateInvocationTurnInput("", nil); !errors.Is(err, errEmptyTurnInput) {
t.Fatalf("empty input without invocations = %v, want errEmptyTurnInput", err)
}
}
func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -7186,7 +7420,7 @@ func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
}
}
func TestDesktopSharedHostProjectMCPWaitsForLaunchApproval(t *testing.T) {
func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T) {
if testing.Short() {
t.Skip("skipping background MCP boot integration test in short mode")
}
@@ -7222,15 +7456,16 @@ url = %q
defer ctrl.Close()
deadline := time.Now().Add(3 * time.Second)
for len(sharedHost.Failures()) == 0 && time.Now().Before(deadline) {
for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
time.Sleep(25 * time.Millisecond)
}
if sharedHost.HasClient("h") {
t.Fatal("project MCP connected before launch approval")
if !sharedHost.HasClient("h") {
t.Fatalf("project MCP did not connect automatically; failures=%+v", sharedHost.Failures())
}
failures := sharedHost.Failures()
if len(failures) != 1 || !failures[0].RequiresLaunchApproval || !strings.Contains(failures[0].Error, "until the user authorizes") {
t.Fatalf("project MCP failure = %+v", failures)
for _, failure := range sharedHost.Failures() {
if failure.Name == "h" && failure.RequiresLaunchApproval {
t.Fatalf("project MCP unexpectedly requested launch approval: %+v", failure)
}
}
app := NewApp()
@@ -7248,12 +7483,12 @@ url = %q
app.activeTabID = "test"
view := app.MCPServers()
if len(view) != 1 || view[0].Name != "h" || view[0].Status != "failed" || view[0].RuntimeState != "issue" || !view[0].RequiresLaunchApproval {
t.Fatalf("MCPServers() = %+v, want project h awaiting launch approval", view)
if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].RuntimeState != "ready" || view[0].RequiresLaunchApproval {
t.Fatalf("MCPServers() = %+v, want trusted connected project h", view)
}
}
func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
func TestProjectMCPViewIsTrustedAndKeepsProjectSource(t *testing.T) {
entry := config.PluginEntry{Name: "project", Source: config.MCPSourceProjectConfig}
connected := withPluginConfig(ServerView{Name: entry.Name, Status: "connected"}, entry)
if connected.RequiresLaunchApproval {
@@ -7262,8 +7497,11 @@ func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
blocked := withPluginConfig(ServerView{
Name: entry.Name, Status: "failed", RequiresLaunchApproval: true,
}, entry)
if !blocked.RequiresLaunchApproval {
t.Fatalf("blocked project MCP lost launch approval action: %+v", blocked)
if blocked.RequiresLaunchApproval {
t.Fatalf("project MCP exposed obsolete launch approval action: %+v", blocked)
}
if blocked.Source != "project" || blocked.ConfigSource != "reasonix.toml" {
t.Fatalf("blocked project MCP source = %q/%q, want project/reasonix.toml", blocked.Source, blocked.ConfigSource)
}
user := withPluginConfig(ServerView{Name: "user", Status: "connected"},
@@ -7271,6 +7509,9 @@ func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
if user.RequiresLaunchApproval {
t.Fatalf("user-config MCP must not be launch-gate governed: %+v", user)
}
if user.Source != "user" || user.ConfigSource != "config.toml" {
t.Fatalf("user MCP source = %q/%q, want user/config.toml", user.Source, user.ConfigSource)
}
}
func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
@@ -8561,6 +8802,71 @@ args = ["serve"]
}
}
func TestRemoveProjectMCPRevealsAndRegistersGlobalFallback(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
userCfg := config.LoadForEdit(config.UserConfigPath())
userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "reasonix.toml")
if err := os.WriteFile(projectPath, []byte(`
[[plugins]]
name = "docs"
command = "project-docs"
`), 0o644); err != nil {
t.Fatal(err)
}
reg := tool.NewRegistry()
var configured []plugin.Spec
ctrl := control.New(control.Options{
Host: plugin.NewHost(),
Registry: reg,
WorkspaceRoot: dir,
MCPConfigureSpec: func(spec *plugin.Spec) {
configured = append(configured, *spec)
},
})
defer ctrl.Close()
projectEntry, found, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !found {
t.Fatalf("load project docs: entry=%+v found=%v err=%v", projectEntry, found, err)
}
if _, err := ctrl.RegisterMCPServerOnDemand(projectEntry); err != nil {
t.Fatalf("register project docs: %v", err)
}
app := NewApp()
app.setTestCtrl(ctrl, "")
app.activeTab().WorkspaceRoot = dir
if err := app.RemoveMCPServer("docs"); err != nil {
t.Fatalf("RemoveMCPServer(docs): %v", err)
}
projectCfg := config.LoadForEdit(projectPath)
if _, found := findPluginEntry(projectCfg.Plugins, "docs"); found {
t.Fatalf("project docs still configured after removal: %+v", projectCfg.Plugins)
}
globalCfg := config.LoadForEdit(config.UserConfigPath())
globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
if !found || globalEntry.Command != "global-docs" {
t.Fatalf("global docs fallback = %+v, found=%v", globalEntry, found)
}
effective, found, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !found || effective.Source != config.MCPSourceUserConfig || effective.Command != "global-docs" {
t.Fatalf("effective docs fallback = %+v, found=%v err=%v", effective, found, err)
}
if len(configured) < 2 || configured[len(configured)-1].Command != "global-docs" {
t.Fatalf("registered specs = %+v, want global fallback registered last", configured)
}
if _, found := reg.Get("mcp__docs__connect"); !found {
t.Fatalf("global fallback connect surface missing; names=%v", reg.Names())
}
}
func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -8786,6 +9092,59 @@ func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
}
}
func TestUpdateMCPServerPreservesProjectTOMLSourceAndGlobalShadow(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
userCfg := config.LoadForEdit(config.UserConfigPath())
userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
projectPath := filepath.Join(dir, "reasonix.toml")
if err := os.WriteFile(projectPath, []byte(`
[[plugins]]
name = "docs"
command = "project-docs"
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir}), "")
defer app.activeCtrl().Close()
app.activeTab().WorkspaceRoot = dir
entry, ok, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !ok || entry.Source != config.MCPSourceProjectConfig {
t.Fatalf("load project docs entry: entry=%+v found=%v err=%v", entry, ok, err)
}
if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
t.Fatal(err)
}
app.activeTab().disabledMCP["docs"] = ServerView{}
if err := app.UpdateMCPServer("docs", MCPServerInput{
Name: "docs", Transport: "stdio", Command: "project-docs-updated",
}); err != nil {
t.Fatalf("UpdateMCPServer(project reasonix.toml docs): %v", err)
}
projectCfg := config.LoadForEdit(projectPath)
projectEntry, found := findPluginEntry(projectCfg.Plugins, "docs")
if !found || projectEntry.Command != "project-docs-updated" {
t.Fatalf("project docs entry = %+v, found=%v", projectEntry, found)
}
globalCfg := config.LoadForEdit(config.UserConfigPath())
globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
if !found || globalEntry.Command != "global-docs" {
t.Fatalf("global shadow changed while editing project entry: %+v, found=%v", globalEntry, found)
}
effective, found, err := desktopEffectiveMCPServer(dir, "docs")
if err != nil || !found || effective.Source != config.MCPSourceProjectConfig || effective.Command != "project-docs-updated" {
t.Fatalf("effective docs after edit = %+v, found=%v err=%v", effective, found, err)
}
}
func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
@@ -9186,7 +9545,7 @@ tier = "lazy"
t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
}
func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
func TestUpdateMCPServerMigratesLegacyTierInProjectSource(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
@@ -9236,19 +9595,19 @@ tier = "lazy"
t.Fatalf("env TOKEN = %q, want preserved env", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
if !ok {
t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("user plugin after migration = %+v", userPlugin)
}
if userPlugin.Tier != "" {
t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
if _, ok := findPluginEntry(userCfg.Plugins, "playwright"); ok {
t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "playwright")
if !ok {
t.Fatalf("playwright should remain in project config: %+v", projectCfg.Plugins)
}
if projectPlugin.Command != "node" || projectPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("project plugin after update = %+v", projectPlugin)
}
if projectPlugin.Tier != "" {
t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
}
view := app.Capabilities()
for _, s := range view.Servers {
@@ -9411,7 +9770,7 @@ name = "codegraph"
t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
}
func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
func TestSetMCPServerTierPreservesProjectSourceAndRecordsConnectFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
@@ -9443,16 +9802,16 @@ tier = "lazy"
t.Fatalf("saved tier = %q, want migrated empty", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
if !ok {
t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
}
if userPlugin.Tier != "" {
t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
if _, ok := findPluginEntry(userCfg.Plugins, "broken"); ok {
t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
}
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "broken")
if !ok {
t.Fatalf("broken should remain in project config: %+v", projectCfg.Plugins)
}
if projectPlugin.Tier != "" {
t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
@@ -9759,7 +10118,11 @@ func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath s
func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
t.Helper()
deadline := time.Now().Add(time.Second)
// Windows release runners can take more than one second to schedule the
// controller's asynchronous completion while the full desktop suite is
// active. Keep a bounded responsiveness check without treating scheduler
// delay as a leaked controller.
deadline := time.Now().Add(5 * time.Second)
for ctrl.Running() {
if time.Now().After(deadline) {
t.Fatal("controller still running")
+79
View File
@@ -35,6 +35,16 @@ func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
}
}
func TestDarwinICNSUsesMacOSIconSafeArea(t *testing.T) {
img := decodeICNSImage(t, "build/darwin/icon.icns", "ic10")
if got, want := img.Bounds(), image.Rect(0, 0, 1024, 1024); got != want {
t.Fatalf("macOS ic10 frame bounds = %v, want %v", got, want)
}
if got, want := alphaBounds(img), image.Rect(100, 100, 924, 924); got != want {
t.Fatalf("macOS icon visible bounds = %v, want %v", got, want)
}
}
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
t.Helper()
@@ -101,6 +111,75 @@ func near(got, want uint8, tolerance uint8) bool {
return want-got <= tolerance
}
func alphaBounds(img image.Image) image.Rectangle {
bounds := img.Bounds()
visible := image.Rectangle{}
found := false
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a == 0 {
continue
}
if !found {
visible = image.Rect(x, y, x+1, y+1)
found = true
continue
}
if x < visible.Min.X {
visible.Min.X = x
}
if y < visible.Min.Y {
visible.Min.Y = y
}
if x >= visible.Max.X {
visible.Max.X = x + 1
}
if y >= visible.Max.Y {
visible.Max.Y = y + 1
}
}
}
return visible
}
func decodeICNSImage(t *testing.T, path, iconType string) image.Image {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) < 8 || string(data[:4]) != "icns" {
t.Fatal("invalid ICNS header")
}
if declaredSize := int(binary.BigEndian.Uint32(data[4:8])); declaredSize != len(data) {
t.Fatalf("ICNS size = %d, want %d", declaredSize, len(data))
}
for offset := 8; offset < len(data); {
if offset+8 > len(data) {
t.Fatalf("truncated ICNS entry header at offset %d", offset)
}
entryType := string(data[offset : offset+4])
entrySize := int(binary.BigEndian.Uint32(data[offset+4 : offset+8]))
if entrySize < 8 || offset+entrySize > len(data) {
t.Fatalf("invalid ICNS entry %q size %d at offset %d", entryType, entrySize, offset)
}
if entryType == iconType {
img, err := png.Decode(bytes.NewReader(data[offset+8 : offset+entrySize]))
if err != nil {
t.Fatalf("decode ICNS entry %q: %v", iconType, err)
}
return img
}
offset += entrySize
}
t.Fatalf("ICNS is missing %q image", iconType)
return nil
}
func decodeICOImage(t *testing.T, path string, size int) image.Image {
t.Helper()
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"fmt"
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/workspacelease"
)
// ActiveWorkView is the structured Desktop contract for work that prevents a
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
type ActiveWorkView struct {
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Cancellable bool `json:"cancellable"`
Jobs []JobView `json:"jobs"`
}
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
// slices are initialized so Wails never sends null to React.
type JobCancelBatchView struct {
Cancelled []string `json:"cancelled"`
NotRunning []string `json:"notRunning"`
}
// BackgroundRuntimeView is one visible or detached runtime with active work.
// TabID is an opaque process-local handle; paths and session writer ids are
// deliberately omitted from this user-facing contract.
type BackgroundRuntimeView struct {
TabID string `json:"tabId"`
Title string `json:"title"`
Detached bool `json:"detached"`
Running bool `json:"running"`
PendingPrompt bool `json:"pendingPrompt"`
Jobs []JobView `json:"jobs"`
}
// WorkspaceConflictView describes a currently-waiting Delivery writer without
// exposing the lock path, process id, or session path.
type WorkspaceConflictView struct {
State string `json:"state"`
OwnerTabID string `json:"ownerTabId,omitempty"`
OwnerTitle string `json:"ownerTitle,omitempty"`
OwnerWork ActiveWorkView `json:"ownerWork"`
CanReveal bool `json:"canReveal"`
CanCreateWorktree bool `json:"canCreateWorktree"`
}
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
view := ActiveWorkView{Jobs: []JobView{}}
if ctrl == nil {
return view
}
status := ctrl.RuntimeStatus()
view.Running = status.Running
view.PendingPrompt = status.PendingPrompt
view.Cancellable = status.Cancellable
for _, job := range ctrl.Jobs() {
view.Jobs = append(view.Jobs, JobView{
ID: job.ID, Kind: job.Kind, Label: job.Label,
Status: job.Status, StartedAt: job.StartedAt,
})
}
return view
}
func (v ActiveWorkView) active() bool {
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
}
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
// preflight aid only; rebuild paths still re-check active work atomically.
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
}
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
// keeps cancelled-but-unwinding jobs visible until their done channels close.
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
seen := map[string]bool{}
for _, raw := range jobIDs {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
cancelled, err := a.CancelJobForTab(tabID, id)
if err != nil {
return result, err
}
if cancelled {
result.Cancelled = append(result.Cancelled, id)
} else {
result.NotRunning = append(result.NotRunning, id)
}
}
return result, nil
}
type backgroundRuntimeSnapshot struct {
id string
ctrl control.SessionAPI
detached bool
title string
}
// BackgroundRuntimes returns every process-local runtime that still needs a
// visible recovery path, including runtimes detached by an explicit tab close.
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
a.mu.RLock()
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.tabs {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
}
for _, tab := range a.detachedSessions {
if tab == nil || seen[tab] {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
}
a.mu.RUnlock()
out := make([]BackgroundRuntimeView, 0, len(snapshots))
for _, snapshot := range snapshots {
work := activeWorkForController(snapshot.ctrl)
if !work.active() {
continue
}
out = append(out, BackgroundRuntimeView{
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
})
}
return out
}
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
a.mu.RLock()
defer a.mu.RUnlock()
if strings.TrimSpace(tabID) == "" {
return a.activeCtrlLocked()
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil {
return nil
}
return tab.Ctrl
}
// RevealBackgroundRuntime activates a visible owner or reopens the exact
// detached session. It never reattaches by workspace alone.
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
a.mu.RLock()
if tab := a.tabs[tabID]; tab != nil {
a.mu.RUnlock()
if err := a.SetActiveTab(tabID); err != nil {
return TabMeta{}, err
}
a.mu.RLock()
current := a.tabs[tabID]
if current == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
meta := a.tabMeta(current, true)
a.mu.RUnlock()
return enrichTabMeta(meta), nil
}
tab := a.tabByEventSinkIDLocked(tabID)
if tab == nil || tab.Ctrl == nil {
a.mu.RUnlock()
return TabMeta{}, fmt.Errorf("background task is no longer available")
}
scope := tab.Scope
workspaceRoot := tab.WorkspaceRoot
topicID := tab.TopicID
sessionPath := tab.currentSessionPath()
a.mu.RUnlock()
if strings.TrimSpace(sessionPath) == "" {
return TabMeta{}, fmt.Errorf("background task session is unavailable")
}
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
}
type workspaceLeaseReporter interface {
WorkspaceLeaseState() workspacelease.State
}
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
return reporter.WorkspaceLeaseState()
}
return workspacelease.State{}
}
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
// currently waiting for. An acquired process-local owner is actionable; when no
// local owner matches, the OS lock is treated as external.
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
a.mu.RLock()
var target *WorkspaceTab
if strings.TrimSpace(tabID) == "" {
target = a.activeTabLocked()
} else {
target = a.tabByEventSinkIDLocked(tabID)
}
if target == nil {
a.mu.RUnlock()
return empty
}
targetCtrl := target.Ctrl
targetWorkspaceRoot := target.WorkspaceRoot
a.mu.RUnlock()
if targetCtrl == nil {
return empty
}
targetState := controllerWorkspaceLeaseState(targetCtrl)
if !targetState.Waiting || targetState.Acquired {
return empty
}
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
if err != nil {
return empty
}
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
a.mu.RLock()
type candidate struct {
id string
ctrl control.SessionAPI
root string
title string
}
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
seen := map[*WorkspaceTab]bool{}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
continue
}
seen[tab] = true
title := strings.TrimSpace(tab.TopicTitle)
if title == "" {
title = strings.TrimSpace(tab.Label)
}
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
}
a.mu.RUnlock()
for _, candidate := range candidates {
root, err := workspacelease.CanonicalWorkspace(candidate.root)
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
continue
}
return WorkspaceConflictView{
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
CanCreateWorktree: availability.Available,
}
}
empty.State = "external"
empty.CanCreateWorktree = availability.Available
return empty
}
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
// by WorkspaceConflictForTab. External writers remain non-actionable.
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
conflict := a.WorkspaceConflictForTab(tabID)
if conflict.State != "local" || conflict.OwnerTabID == "" {
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
}
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
}
const stopAndCloseGrace = 15 * time.Second
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
// choice. stop_and_close never removes the tab until all owned work is idle.
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
switch strings.TrimSpace(policy) {
case "keep_running":
return a.closeTab(tabID, true)
case "stop_and_close":
ctrl := a.ctrlForRuntimeTabID(tabID)
if ctrl == nil {
return a.closeTab(tabID, false)
}
ctrl.Cancel()
jobs := ctrl.Jobs()
ids := make([]string, 0, len(jobs))
for _, job := range jobs {
ids = append(ids, job.ID)
}
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
return err
}
deadline := time.NewTimer(stopAndCloseGrace)
defer deadline.Stop()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
for {
if !activeWorkForController(ctrl).active() {
return a.closeTab(tabID, false)
}
select {
case <-deadline.C:
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
case <-ticker.C:
}
}
default:
return fmt.Errorf("unknown close policy %q", policy)
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"testing"
"reasonix/internal/control"
"reasonix/internal/jobs"
"reasonix/internal/remote/workbench/client"
"reasonix/internal/workspacelease"
)
type backgroundRuntimeController struct {
control.SessionAPI
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
cancelled []string
turnCancel int
}
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
func (c *backgroundRuntimeController) Jobs() []jobs.View {
return append([]jobs.View(nil), c.jobs...)
}
func (c *backgroundRuntimeController) CancelJob(id string) bool {
for _, job := range c.jobs {
if job.ID == id {
c.cancelled = append(c.cancelled, id)
return true
}
}
return false
}
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
return c.lease
}
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
targetCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
}
focusedCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
}
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
app := &App{
tabs: map[string]*WorkspaceTab{"focused": focused},
tabOrder: []string{"focused"},
activeTabID: "focused",
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
}
work := app.ActiveWorkForTab("target")
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
}
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
if err != nil {
t.Fatalf("CancelJobsForTab: %v", err)
}
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
t.Fatalf("CancelJobsForTab result = %+v", result)
}
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
}
runtimes := app.BackgroundRuntimes()
if len(runtimes) != 2 {
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
}
var foundDetached bool
for _, runtime := range runtimes {
if runtime.TabID == "target" {
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
}
}
if !foundDetached {
t.Fatalf("detached runtime missing from %+v", runtimes)
}
}
func TestCancelJobForLocalRuntimeWhileRemoteWorkbenchIsActive(t *testing.T) {
localCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
}
app := &App{
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
tabOrder: []string{"local"},
activeTabID: "local",
}
kernel := app.workbench()
_, generation, err := kernel.targets.BeginRemoteConnect("remote-host", "/srv/work")
if err != nil {
t.Fatalf("BeginRemoteConnect: %v", err)
}
if err := kernel.targets.MarkRemoteConnected(generation); err != nil {
t.Fatalf("MarkRemoteConnected: %v", err)
}
if _, _, _, err := kernel.targets.ActivateRemote(generation); err != nil {
t.Fatalf("ActivateRemote: %v", err)
}
kernel.mu.Lock()
kernel.remote = &client.Client{}
kernel.remoteGen = generation
kernel.remoteTabID = "remote"
kernel.mu.Unlock()
listed := app.JobsForTab("local")
if len(listed) != 1 || listed[0].ID != "job-local" {
t.Fatalf("JobsForTab(local) = %+v, want local job while Remote Workbench is active", listed)
}
cancelled, err := app.CancelJobForTab("local", "job-local")
if err != nil {
t.Fatalf("CancelJobForTab(local): %v", err)
}
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
}
}
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
root := t.TempDir()
waiterCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{Running: true},
lease: workspacelease.State{Waiting: true},
}
ownerCtrl := &backgroundRuntimeController{
status: control.RuntimeStatus{BackgroundJobs: 1},
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
lease: workspacelease.State{Acquired: true},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"},
activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
}
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
}
}
+10 -4
View File
@@ -185,7 +185,7 @@ func (h *botBridgeHub) observe(tabID string, e event.Event) {
// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
return
}
h.enqueue(h.turnDoneNotification(tabID, e.Err))
h.enqueue(h.turnDoneNotification(tabID, e))
}
}
@@ -359,15 +359,21 @@ func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridg
return desktopBridgeNotification{text: textFor, card: card}
}
func (h *botBridgeHub) turnDoneNotification(tabID string, err error) desktopBridgeNotification {
func (h *botBridgeHub) turnDoneNotification(tabID string, e event.Event) desktopBridgeNotification {
label := h.tabLabel(tabID)
if err != nil {
if e.Outcome == event.TurnOutcomeRecoveryPaused {
return desktopBridgeNotification{text: constText(fmt.Sprintf(
"⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
label,
))}
}
if e.Err != nil {
// Error text can contain paths/tokens; only detail it in a private chat.
return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
if isSharedChat(route.ChatType) {
return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
}
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(err.Error(), botBridgeErrTextLimit))
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
}}
}
return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
+15
View File
@@ -357,6 +357,21 @@ func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
}
}
func TestBridgeRecoveryPauseNotifiesAsControlledPause(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
env.hub.SetWatch(testWatchRoute(), true)
env.hub.observe("tab-1", event.Event{
Kind: event.TurnDone,
Err: errors.New("automatic recovery paused"),
Outcome: event.TurnOutcomeRecoveryPaused,
})
call := env.waitNotification(t)
if strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "已暂停自动重试") || !strings.Contains(call.msg.Text, "继续") {
t.Fatalf("recovery pause text = %q, want a neutral actionable pause notice", call.msg.Text)
}
}
func TestBridgeAskAnswerRoundTrip(t *testing.T) {
env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
env.hub.SetWatch(testWatchRoute(), true)
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
<!-- Legacy ICNS renderers do not apply the modern macOS icon mask. Keep the
rounded-square artwork in the 824px icon grid used by peer applications. -->
<g transform="translate(100 100) scale(2.06)">
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!--
Polkit policy for Reasonix desktop in-app .deb upgrades.
The action only authorizes the fixed root-owned helper with argv1=install.
Active sessions require auth_admin every time; authorization is not retained.
-->
<policyconfig>
<vendor>Reasonix</vendor>
<vendor_url>https://reasonix.io</vendor_url>
<action id="io.reasonix.desktop.update">
<description gettext-domain="reasonix-desktop">Install a Reasonix desktop update</description>
<description xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 桌面端更新</description>
<description xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 桌面端更新</description>
<message gettext-domain="reasonix-desktop">Authentication is required to install a Reasonix update</message>
<message xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 更新需要管理员授权</message>
<message xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 更新需要管理員授權</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/reasonix/reasonix-update-helper</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">install</annotate>
</action>
</policyconfig>
+19 -8
View File
@@ -1,12 +1,13 @@
# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
# below are relative to desktop/). The .deb is a human-download artifact only — like
# the macOS .dmg — so the Linux updater channel stays the .tar.gz and cmd/sign's
# manifest skips .deb files.
# below are relative to desktop/). Portable clients keep using the .tar.gz under
# platforms[]; this .deb is the native package channel (native_packages) and also
# remains a human-download artifact.
#
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION is the tag
# minus a leading "v" and any prerelease suffix (a strict X.Y.Z that dpkg accepts);
# DEB_ARCH is the Go arch name, which already matches Debian's (amd64, arm64).
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION follows
# Debian version ordering: stable X.Y.Z, prereleases as X.Y.Z~rc.N / X.Y.Z~canary.N
# so they sort below the matching stable package. DEB_ARCH is the Go arch name,
# which already matches Debian's (amd64, arm64).
name: "reasonix-desktop"
arch: "${DEB_ARCH}"
platform: "linux"
@@ -21,22 +22,32 @@ homepage: "https://github.com/esengine/DeepSeek-Reasonix"
license: "MIT"
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
# pkexec is required for the in-app Polkit authorization update path.
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
- pkexec
contents:
- src: ./build/bin/reasonix-desktop
dst: /usr/bin/reasonix-desktop
file_info:
mode: 0755
- src: ./build/bin/reasonix-guard
dst: /usr/bin/reasonix-guard
- src: ./build/bin/reasonix-launcher
dst: /usr/bin/reasonix-launcher
file_info:
mode: 0755
- src: ./build/bin/reasonix
dst: /usr/bin/reasonix
file_info:
mode: 0755
- src: ./build/bin/reasonix-update-helper
dst: /usr/lib/reasonix/reasonix-update-helper
file_info:
mode: 0755
- src: ./build/linux/io.reasonix.desktop.update.policy
dst: /usr/share/polkit-1/actions/io.reasonix.desktop.update.policy
file_info:
mode: 0644
- src: ./build/linux/reasonix.desktop
dst: /usr/share/applications/reasonix.desktop
file_info:
+1 -1
View File
@@ -2,7 +2,7 @@
Type=Application
Name=Reasonix
Comment=Reasonix desktop — a Wails shell around the Go kernel
Exec=reasonix-guard launch --detach
Exec=reasonix-launcher
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
+108 -23
View File
@@ -83,8 +83,13 @@ LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified updat
LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"
LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
## Preserve the first-pass generated uninstaller so the release workflow can
## Authenticode-sign it together with the other installed payload files.
## The second pass provides ARG_REASONIX_SIGNED_UNINSTALLER and embeds that
## signed binary instead of generating another unsigned uninstaller.
!ifndef ARG_REASONIX_SIGNED_UNINSTALLER
!uninstfinalize 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'
!endif
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
@@ -95,8 +100,12 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_UNLOCK_RETRIES 60
Var ReasonixUpdateMode
Var ReasonixStageMode
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
@@ -106,7 +115,11 @@ ShowInstDetails show # This will always show the installation details.
## wails.deleteUninstaller, which write HKLM and would fail without admin rights.
####
!macro reasonix.writeUninstaller
!ifdef ARG_REASONIX_SIGNED_UNINSTALLER
File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"
!else
WriteUninstaller "$INSTDIR\uninstall.exe"
!endif
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
@@ -140,6 +153,7 @@ Function .onInit
; destination, then closes automatically after the file copy so the helper
; can relaunch Reasonix. A normal manual installer keeps the full wizard.
StrCpy $ReasonixUpdateMode "0"
StrCpy $ReasonixStageMode "0"
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/REASONIXUPDATE=" $R1
@@ -148,6 +162,13 @@ Function .onInit
StrCpy $ReasonixUpdateMode "1"
reasonix_update_mode_done:
ClearErrors
${GetOptions} $R0 "/REASONIXSTAGE=" $R2
IfErrors reasonix_stage_mode_done
StrCmp $R2 "1" 0 reasonix_stage_mode_done
StrCpy $ReasonixStageMode "1"
reasonix_stage_mode_done:
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
; missing. Older installers still wrote DisplayIcon, so use its parent folder
@@ -193,12 +214,21 @@ Function reasonix.waitForExecutableUnlock
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_guard
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_versioned_target:
; A same-version recovery install replaces this directory transactionally.
; Detect the running active binary before asking the Go activator to rename it.
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
@@ -253,49 +283,102 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
; the new helper's staging extract). Do not write shortcuts/uninstaller.
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
; with a permanent thin launcher at InstallRoot. Guard is only present in
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
reasonix_stage_payload:
SetOutPath $INSTDIR
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
; Portable archives expose Reasonix.exe as a second copy of the Guard
; launcher. Preserve that layout only when upgrading an existing portable
; directory, and keep the alias in lockstep with the canonical launcher.
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 reasonix_no_portable_entry
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
reasonix_no_portable_entry:
; Keep the Guard launcher as the target while taking the visible shortcut
; icon from the Wails application. The launcher embeds the same icon too,
; but an explicit icon source prevents stale/generic taskbar pins after an
; in-place upgrade from a launcher build that had no Windows resources.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
Goto reasonix_section_done
reasonix_normal_install:
; Extract into an install-local temporary directory, then let the signed Go
; activator validate the complete release unit, transactionally publish the
; version/root entries, and strictly atomically replace current.json last.
; The normal/recovery installer therefore shares the same commit protocol as
; automatic updates instead of writing live files or current.json in place.
System::Call 'kernel32::GetCurrentProcessId() i .R8'
CreateDirectory "$INSTDIR\versions"
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
RMDir /r "$R9"
CreateDirectory "$R9"
SetOutPath "$R9"
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!else
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
!endif
SetOutPath "$PLUGINSDIR"
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
!else
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
!endif
ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch' $0
StrCmp $0 "0" reasonix_layout_activated
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
RMDir /r "$R9"
SetErrorLevel 1
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
reasonix_layout_activated:
RMDir /r "$R9"
SetOutPath "$INSTDIR"
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
!if /FileExists "${REASONIX_LAUNCHER}"
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
!endif
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
reasonix_section_done:
SectionEnd
Section "uninstall"
@@ -303,13 +386,15 @@ Section "uninstall"
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
; Precision uninstall: delete main application files
; Precision uninstall: flat leftovers, thin entry points, and version trees.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$INSTDIR\current.json"
RMDir /r "$INSTDIR\versions"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+107 -20
View File
@@ -10,10 +10,14 @@
// encrypted minisign private key in $MINISIGN_PRIVATE_KEY
// (decrypted with $MINISIGN_PASSWORD).
//
// manifest <dir> <ver> <tag> Scan <dir> for the per-platform artifacts, compute
// manifest <dir> <ver> <tag> [notes-ver]
// Scan <dir> for the per-platform artifacts, compute
// size + sha256, and write <dir>/latest.json with GitHub
// release download URLs. The R2 mirror step rewrites those
// URLs to the CDN afterwards (url + sig fields together).
//
// windows-payload <dir> <ver> Write a deterministic manifest of the exact
// executables embedded in the Windows installer.
package main
import (
@@ -37,6 +41,11 @@ import (
// generator and the updater agree on update.PlatformKey output.
var platforms = []string{"darwin-arm64", "darwin-amd64", "windows-amd64", "windows-arm64", "linux-amd64"}
var websiteDownloads = map[string]struct{}{
"Reasonix-darwin-universal.dmg": {},
"Reasonix-windows-amd64.zip": {},
}
func main() {
if len(os.Args) < 2 {
usage()
@@ -46,10 +55,19 @@ func main() {
case "sign":
err = signFiles(os.Args[2:])
case "manifest":
if len(os.Args) != 5 {
if len(os.Args) != 5 && len(os.Args) != 6 {
usage()
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4])
notesVersion := os.Args[3]
if len(os.Args) == 6 {
notesVersion = os.Args[5]
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
case "windows-payload":
if len(os.Args) != 4 {
usage()
}
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
case "genkey":
if len(os.Args) != 3 {
usage()
@@ -70,10 +88,34 @@ func main() {
}
func usage() {
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
os.Exit(2)
}
func genWindowsPayloadManifest(dir, version string) error {
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("Windows payload %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("Windows payload %s is not a regular file", name)
}
_, sum, err := hashFile(path)
if err != nil {
return err
}
hashes[name] = sum
}
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
}
// verifyFile checks <file> against <file>.minisig using the embedded public key —
// the same check the updater runs before applying. A self-test that the signing
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
@@ -164,15 +206,29 @@ func signFiles(files []string) error {
// genManifest scans dir for the per-platform artifacts and writes dir/latest.json.
// version is the semver compared by the updater (e.g. "v1.1.0"); tag is the GitHub
// release tag used in download URLs (e.g. "desktop-v1.1.0").
func genManifest(dir, version, tag string) error {
//
// Portable updater channels land in platforms (tarballs/installers). Debian/Ubuntu
// .deb packages land only in native_packages so older clients keep resolving the
// tarball under platforms["linux-amd64"].
func genManifest(dir, version, tag string, notesVersions ...string) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" || repo == "esengine/reasonix" {
repo = "esengine/DeepSeek-Reasonix"
}
notesVersion := version
if len(notesVersions) > 1 {
return fmt.Errorf("manifest: expected at most one release-notes version")
}
if len(notesVersions) == 1 {
notesVersion = notesVersions[0]
}
m := update.Manifest{
Version: version,
DownloadPage: "https://reasonix.io/?download=desktop#start",
Platforms: map[string]update.Asset{},
Version: version,
DownloadPage: "https://reasonix.io/?download=desktop#start",
ReleaseNotesURL: "https://reasonix.io/changelog/" + notesVersion + "/",
Platforms: map[string]update.Asset{},
NativePackages: map[string]update.Asset{},
Downloads: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
if err != nil {
@@ -183,8 +239,9 @@ func genManifest(dir, version, tag string) error {
if e.IsDir() || strings.HasSuffix(name, ".minisig") || name == "latest.json" {
continue
}
key := matchPlatform(name)
if key == "" {
key, kind := matchArtifact(name)
_, websiteDownload := websiteDownloads[name]
if key == "" && !websiteDownload {
continue
}
size, sum, err := hashFile(filepath.Join(dir, name))
@@ -192,12 +249,31 @@ func genManifest(dir, version, tag string) error {
return err
}
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
m.Platforms[key] = update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
asset := update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
if websiteDownload {
m.Downloads[name] = asset
fmt.Printf("manifest download: %s (%d bytes)\n", name, size)
}
if key != "" {
switch kind {
case artifactNative:
m.NativePackages[key] = asset
fmt.Printf("manifest native: %s -> %s (%d bytes)\n", key, name, size)
default:
m.Platforms[key] = asset
fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
}
}
}
if len(m.Platforms) == 0 {
return fmt.Errorf("manifest: no platform artifacts found in %s", dir)
}
if len(m.NativePackages) == 0 {
m.NativePackages = nil // omit empty map so older tooling sees a clean document
}
if len(m.Downloads) == 0 {
m.Downloads = nil
}
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
@@ -205,24 +281,35 @@ func genManifest(dir, version, tag string) error {
return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
}
// matchPlatform returns the platform key embedded in a file name, or "" if none.
func matchPlatform(name string) string {
// The .deb is a human-download package (like the macOS .dmg); the Linux updater
// channel is the .tar.gz. Skip it so it doesn't shadow the tarball's linux-amd64 key.
const (
artifactPortable = "portable"
artifactNative = "native"
)
// matchArtifact returns the platform key and channel kind embedded in a file name,
// or ("", "") if the file is not a publishable updater/download artifact.
func matchArtifact(name string) (key, kind string) {
// .deb is the Linux native package channel. Keep it out of platforms so the
// tarball remains the portable linux-amd64 key for older clients.
if strings.HasSuffix(name, ".deb") {
return ""
for _, p := range platforms {
if strings.Contains(name, p) {
return p, artifactNative
}
}
return "", ""
}
// The Windows updater channel is the per-arch -installer.exe; the portable .zip
// is a human download, so skip it or it would shadow the installer's key.
if strings.Contains(name, "windows-") && !strings.HasSuffix(name, "-installer.exe") {
return ""
return "", ""
}
for _, p := range platforms {
if strings.Contains(name, p) {
return p
return p, artifactPortable
}
}
return ""
return "", ""
}
// hashFile returns the size and lowercase-hex SHA-256 of a file, streaming it so
+110 -2
View File
@@ -55,6 +55,7 @@ func TestGenManifest(t *testing.T) {
names := []string{
"Reasonix-darwin-arm64.zip",
"Reasonix-darwin-amd64.zip",
"Reasonix-darwin-universal.dmg",
"Reasonix-windows-amd64-installer.exe",
"Reasonix-windows-amd64.zip", // portable download, not the updater channel
"Reasonix-windows-arm64-installer.exe",
@@ -88,6 +89,9 @@ func TestGenManifest(t *testing.T) {
if m.DownloadPage != "https://reasonix.io/?download=desktop#start" {
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
}
if m.ReleaseNotesURL != "https://reasonix.io/changelog/v1.2.0/" {
t.Fatalf("release_notes_url = %q, want exact version history", m.ReleaseNotesURL)
}
if len(m.Platforms) != 5 {
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
}
@@ -114,8 +118,8 @@ func TestGenManifest(t *testing.T) {
if !strings.HasSuffix(arm.URL, "/Reasonix-windows-arm64-installer.exe") {
t.Fatalf("windows-arm64 url = %q, want the installer, not the portable zip", arm.URL)
}
// The Linux updater channel must stay the .tar.gz; the co-located .deb is a
// human download and must not shadow the linux-amd64 key.
// The Linux portable channel stays the .tar.gz; the co-located .deb lands
// only in native_packages so older clients keep resolving platforms["linux-amd64"].
lin, ok := m.Platforms["linux-amd64"]
if !ok {
t.Fatal("linux-amd64 missing")
@@ -123,4 +127,108 @@ func TestGenManifest(t *testing.T) {
if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") {
t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL)
}
if lin.Sig == "" || lin.SHA256 == "" || lin.Size == 0 {
t.Fatalf("linux portable asset incomplete: %+v", lin)
}
deb, ok := m.NativePackages["linux-amd64"]
if !ok {
t.Fatal("native_packages linux-amd64 missing")
}
if !strings.HasSuffix(deb.URL, "/Reasonix-linux-amd64.deb") {
t.Fatalf("native linux-amd64 url = %q, want the .deb", deb.URL)
}
if deb.Sig != deb.URL+".minisig" || deb.SHA256 == "" || deb.Size == 0 {
t.Fatalf("native linux asset incomplete: %+v", deb)
}
if len(m.Downloads) != 2 {
t.Fatalf("want 2 website downloads, got %d: %+v", len(m.Downloads), m.Downloads)
}
for _, name := range []string{"Reasonix-darwin-universal.dmg", "Reasonix-windows-amd64.zip"} {
asset, ok := m.Downloads[name]
if !ok {
t.Fatalf("website download %q missing", name)
}
if !strings.HasSuffix(asset.URL, "/"+name) ||
asset.Sig != asset.URL+".minisig" ||
asset.SHA256 == "" ||
asset.Size == 0 {
t.Fatalf("website download %q incomplete: %+v", name, asset)
}
}
}
func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "Reasonix-linux-amd64.tar.gz"), []byte("rc"), 0o644); err != nil {
t.Fatal(err)
}
if err := genManifest(dir, "v1.3.0-rc.1", "desktop-v1.3.0-rc.1", "v1.3.0"); err != nil {
t.Fatalf("genManifest: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
var m update.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.ReleaseNotesURL != "https://reasonix.io/changelog/v1.3.0/" {
t.Fatalf("release_notes_url = %q, want stable base history", m.ReleaseNotesURL)
}
}
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
// platform key is skipped rather than inventing a native_packages entry.
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
dir := t.TempDir()
for _, n := range []string{
"Reasonix-linux-amd64.tar.gz",
"Reasonix-mystery.deb",
} {
if err := os.WriteFile(filepath.Join(dir, n), []byte(n), 0o644); err != nil {
t.Fatal(err)
}
}
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := genManifest(dir, "v1.2.0", "desktop-v1.2.0"); err != nil {
t.Fatalf("genManifest: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
var m update.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.NativePackages) != 0 {
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
}
}
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
dir := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
if err != nil {
t.Fatal(err)
}
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
if err != nil {
t.Fatal(err)
}
for _, name := range update.WindowsPayloadFileNames() {
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
if hashes[name] != want {
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
}
}
}
+4 -4
View File
@@ -4,10 +4,10 @@ import "fmt"
func installerCommandLine(installer, dir string) string {
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
// The installer skips its welcome and directory pages in this mode, keeps
// the current install directory fixed, and closes itself after the file copy
// completes so this helper can relaunch Reasonix.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1`, installer)
// The current helper also requires staging-only extraction: NSIS never writes
// the live install, and the helper compare-and-publishes the claimed release
// unit after validating every staged member.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
if dir != "" {
line += " /D=" + dir
}
+4 -1
View File
@@ -7,7 +7,7 @@ import (
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /D=D:\Tools\Reasonix App`
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
@@ -17,4 +17,7 @@ func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
if !strings.Contains(got, " /REASONIXSTAGE=1") {
t.Fatalf("auto-update must extract away from the live install, got %q", got)
}
}
@@ -0,0 +1,10 @@
//go:build !windows
package main
import "os"
// Windows verifies each executable with the OS Authenticode policy while the
// same read handle is held. Non-Windows builds keep the release-unit loader
// testable without importing platform-specific trust APIs.
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
@@ -0,0 +1,97 @@
//go:build windows
package main
import (
"fmt"
"io"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
// reads the payload through one handle that disallows concurrent writes,
// deletes, and renames. The bytes handed to the publisher therefore cannot
// change between signature verification and publication.
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
path16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, fmt.Errorf("open staged payload handle")
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
if !fileInfo.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
trustFile := &windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
File: handle,
}
trustData := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_NONE,
UnionChoice: windows.WTD_CHOICE_FILE,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_EXECUTE,
}
verifyErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
if verifyErr != nil {
if closeErr != nil {
return nil, fmt.Errorf("Authenticode verification failed: %v (release trust state: %w)", verifyErr, closeErr)
}
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(file)
}
+489
View File
@@ -0,0 +1,489 @@
//go:build linux
// Command reasonix-update-helper (Linux) installs a verified .deb under Polkit.
// It is invoked only via pkexec with fixed argv and re-validates every input as
// root before calling apt-get. The unprivileged desktop process never runs apt.
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"
"reasonix/desktop/internal/update"
)
// Stable exit codes observed by the desktop installer.
const (
exitOK = 0
exitUsage = 2
exitNotRoot = 10
exitBadInput = 11
exitVerifyFailed = 12
exitPackageRejected = 13
exitBusy = 14
exitInstallFailed = 15
exitPostVerify = 16
)
const (
packageName = "reasonix-desktop"
dpkgDebPath = "/usr/bin/dpkg-deb"
dpkgQueryPath = "/usr/bin/dpkg-query"
dpkgPath = "/usr/bin/dpkg"
aptGetPath = "/usr/bin/apt-get"
// maxInputBytes bounds untrusted package/signature files before they are
// copied into the root temp directory (desktop .deb + minisig).
maxInputBytes = 512 << 20 // 512 MiB
// phasePrefix is a single-line protocol the desktop parses from stderr so
// the UI can leave "authorizing" once Polkit has launched this helper and
// validation finished, before apt-get starts.
phasePrefix = "REASONIX_UPDATE_PHASE="
)
type helperResult struct {
OK bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
Code string `json:"code,omitempty"`
}
// installDeps holds the privileged install seams. Production uses realDeps();
// tests inject fakes so every branch is deterministic without root/apt.
type installDeps struct {
geteuid func() int
getenv func(string) string
mkTempDir func() (string, error)
removeAll func(string) error
copyOwnedRegular func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error
readFile func(string) ([]byte, error)
verify func(data, sig []byte) error
inspectDeb func(path string) (debIdentity, error)
installedVersion func() (string, error)
compareVersions func(a, b string) (int, error)
aptInstall func(pkgPath string, allowDowngrade bool) error
verifyInstalled func(want string) error
writePhase func(phase string)
writeResult func(helperResult)
goArch string
maxInputBytes int64
}
func realDeps() installDeps {
return installDeps{
geteuid: os.Geteuid,
getenv: os.Getenv,
mkTempDir: func() (string, error) { return os.MkdirTemp("", "reasonix-update-*") },
removeAll: os.RemoveAll,
copyOwnedRegular: copyOwnedRegularFile,
readFile: os.ReadFile,
verify: update.Verify,
inspectDeb: inspectDeb,
installedVersion: installedPackageVersion,
compareVersions: compareDebVersions,
aptInstall: aptInstallOnlyUpgrade,
verifyInstalled: verifyInstalled,
writePhase: writePhaseLine,
writeResult: writeResultJSON,
goArch: runtime.GOARCH,
maxInputBytes: maxInputBytes,
}
}
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
return runWith(realDeps(), args)
}
func runWith(d installDeps, args []string) int {
if len(args) == 0 {
d.writeResult(helperResult{OK: false, Error: "missing command", Code: "usage"})
return exitUsage
}
switch args[0] {
case "install":
return runInstall(d, args[1:])
default:
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
return exitUsage
}
}
func runInstall(d installDeps, args []string) int {
fs := flag.NewFlagSet("install", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var packagePath, signaturePath string
fs.StringVar(&packagePath, "package", "", "path to the verified .deb")
fs.StringVar(&signaturePath, "signature", "", "path to the detached .minisig")
if err := fs.Parse(args); err != nil {
d.writeResult(helperResult{OK: false, Error: "invalid arguments", Code: "usage"})
return exitUsage
}
if packagePath == "" || signaturePath == "" || fs.NArg() != 0 {
d.writeResult(helperResult{OK: false, Error: "install requires --package and --signature", Code: "usage"})
return exitUsage
}
if d.geteuid() != 0 {
d.writeResult(helperResult{OK: false, Error: "helper must run as root", Code: "not_root"})
return exitNotRoot
}
pkUID, err := strconv.Atoi(strings.TrimSpace(d.getenv("PKEXEC_UID")))
if err != nil || pkUID < 0 {
d.writeResult(helperResult{OK: false, Error: "missing or invalid PKEXEC_UID", Code: "not_root"})
return exitNotRoot
}
tmpDir, err := d.mkTempDir()
if err != nil {
d.writeResult(helperResult{OK: false, Error: "create temp dir failed", Code: "install_failed"})
return exitInstallFailed
}
// Ensure root-only access before any untrusted bytes land here.
if err := os.Chmod(tmpDir, 0o700); err != nil {
_ = d.removeAll(tmpDir)
d.writeResult(helperResult{OK: false, Error: "secure temp dir failed", Code: "install_failed"})
return exitInstallFailed
}
defer func() { _ = d.removeAll(tmpDir) }()
maxBytes := d.maxInputBytes
if maxBytes <= 0 {
maxBytes = maxInputBytes
}
pkgCopy := filepath.Join(tmpDir, "package.deb")
sigCopy := filepath.Join(tmpDir, "package.deb.minisig")
if err := d.copyOwnedRegular(packagePath, pkgCopy, 0o600, pkUID, maxBytes); err != nil {
d.writeResult(helperResult{OK: false, Error: "invalid package input", Code: "bad_input"})
return exitBadInput
}
if err := d.copyOwnedRegular(signaturePath, sigCopy, 0o600, pkUID, maxBytes); err != nil {
d.writeResult(helperResult{OK: false, Error: "invalid signature input", Code: "bad_input"})
return exitBadInput
}
pkgData, err := d.readFile(pkgCopy)
if err != nil {
d.writeResult(helperResult{OK: false, Error: "read package failed", Code: "bad_input"})
return exitBadInput
}
sigData, err := d.readFile(sigCopy)
if err != nil {
d.writeResult(helperResult{OK: false, Error: "read signature failed", Code: "bad_input"})
return exitBadInput
}
// Re-verify as root; never trust the unprivileged process's prior check.
if err := d.verify(pkgData, sigData); err != nil {
d.writeResult(helperResult{OK: false, Error: "signature verification failed", Code: "verify_failed"})
return exitVerifyFailed
}
candidate, err := d.inspectDeb(pkgCopy)
if err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
if err := acceptDebIdentity(candidate, d.goArch); err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
installed, err := d.installedVersion()
if err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
cmp, err := d.compareVersions(candidate.Version, installed)
if err != nil {
d.writeResult(helperResult{OK: false, Error: "version compare failed", Code: "package_rejected"})
return exitPackageRejected
}
allowDowngrade, err := acceptVersionTransition(cmp, candidate.Version, installed)
if err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
return exitPackageRejected
}
// Polkit already authorized this process; validation is complete. Tell the
// desktop to leave "authorizing" before the long apt-get call.
d.writePhase("installing")
if err := d.aptInstall(pkgCopy, allowDowngrade); err != nil {
code := "install_failed"
exit := exitInstallFailed
if isPackageManagerBusy(err) {
code = "package_manager_busy"
exit = exitBusy
}
d.writeResult(helperResult{OK: false, Error: sanitizeHelperError(err), Code: code})
return exit
}
if err := d.verifyInstalled(candidate.Version); err != nil {
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_verify_failed"})
return exitPostVerify
}
d.writeResult(helperResult{OK: true, Version: candidate.Version})
return exitOK
}
type debIdentity struct {
Package string
Version string
Arch string
}
// acceptDebIdentity enforces package name and architecture. Pure for tests.
func acceptDebIdentity(id debIdentity, goArch string) error {
if id.Package != packageName {
return errors.New("package name rejected")
}
wantArch := goArch
if wantArch == "386" {
wantArch = "i386"
}
if id.Arch != wantArch && id.Arch != "all" {
return errors.New("package architecture rejected")
}
if id.Version == "" {
return errors.New("package version missing")
}
return nil
}
var (
stableDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
previewDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)~preview\.(0|[1-9][0-9]*)$`)
)
// acceptVersionTransition permits ordinary upgrades and the one intentional
// downgrade: replacing a public Preview package with a public Stable package
// after the user switches channels.
func acceptVersionTransition(cmp int, candidate, installed string) (bool, error) {
switch {
case cmp > 0:
return false, nil
case cmp == 0:
return false, errors.New("candidate version is not strictly newer")
case stableDebVersionRE.MatchString(candidate) && previewDebVersionRE.MatchString(installed):
return true, nil
default:
return false, errors.New("candidate downgrade is not an allowed preview-to-stable transition")
}
}
// aptInstallArgv is the fixed absolute apt-get argv (never shell). Pure for tests.
func aptInstallArgv(pkgPath string, allowDowngrade bool) []string {
argv := []string{
aptGetPath,
"install",
"--assume-yes",
"--only-upgrade",
"--no-remove",
}
if allowDowngrade {
argv = append(argv, "--allow-downgrades")
}
return append(argv, pkgPath)
}
func inspectDeb(path string) (debIdentity, error) {
pkg, err := dpkgDebField(path, "Package")
if err != nil {
return debIdentity{}, errors.New("dpkg-deb inspection failed")
}
ver, err := dpkgDebField(path, "Version")
if err != nil {
return debIdentity{}, errors.New("dpkg-deb inspection failed")
}
arch, err := dpkgDebField(path, "Architecture")
if err != nil {
return debIdentity{}, errors.New("dpkg-deb inspection failed")
}
return debIdentity{Package: pkg, Version: ver, Arch: arch}, nil
}
func dpkgDebField(path, field string) (string, error) {
out, err := exec.Command(dpkgDebPath, "-f", path, field).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func installedPackageVersion() (string, error) {
out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Version}", packageName).Output()
if err != nil {
return "", errors.New("installed package not found")
}
v := strings.TrimSpace(string(out))
if v == "" {
return "", errors.New("installed package version empty")
}
return v, nil
}
// compareDebVersions returns >0 when a > b using dpkg --compare-versions.
func compareDebVersions(a, b string) (int, error) {
if err := exec.Command(dpkgPath, "--compare-versions", a, "gt", b).Run(); err == nil {
return 1, nil
}
if err := exec.Command(dpkgPath, "--compare-versions", a, "eq", b).Run(); err == nil {
return 0, nil
}
if err := exec.Command(dpkgPath, "--compare-versions", a, "lt", b).Run(); err == nil {
return -1, nil
}
return 0, errors.New("compare-versions failed")
}
func aptInstallOnlyUpgrade(pkgPath string, allowDowngrade bool) error {
argv := aptInstallArgv(pkgPath, allowDowngrade)
cmd := exec.Command(argv[0], argv[1:]...)
// Fixed absolute argv only — never shell.
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return nil
}
func verifyInstalled(wantVersion string) error {
out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Status}\n${Version}", packageName).Output()
if err != nil {
return errors.New("post-install dpkg-query failed")
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return errors.New("post-install package state incomplete")
}
if strings.TrimSpace(lines[0]) != "install ok installed" {
return errors.New("package not in install ok installed state")
}
if strings.TrimSpace(lines[1]) != wantVersion {
return errors.New("installed version mismatch")
}
return nil
}
// copyOwnedRegularFile opens src with O_NOFOLLOW only (fail closed — no Lstat/Open
// fallback), requires a regular file owned by ownerUID, enforces maxBytes, and
// writes a root-owned copy at dst. Ownership is checked on the opened fd.
func copyOwnedRegularFile(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
f, err := os.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
// Fail closed: never fall back to a followable open (TOCTOU).
return fmt.Errorf("open without following links: %w", err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("not a regular file")
}
if maxBytes > 0 && info.Size() > maxBytes {
return errors.New("input exceeds size bound")
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("stat owner unavailable")
}
if int(st.Uid) != ownerUID {
return errors.New("input owner does not match PKEXEC_UID")
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, mode)
if err != nil {
return err
}
defer out.Close()
// Cap the copy even if size grew after Stat (regular files can still be
// truncated/appended by the owner on some filesystems; bound the bytes we take).
limited := io.LimitReader(f, maxBytes+1)
n, err := io.Copy(out, limited)
if err != nil {
return err
}
if maxBytes > 0 && n > maxBytes {
return errors.New("input exceeds size bound")
}
return out.Close()
}
func isPackageManagerBusy(err error) bool {
if err == nil {
return false
}
low := strings.ToLower(err.Error())
return strings.Contains(low, "could not get lock") ||
strings.Contains(low, "unable to acquire the dpkg frontend lock") ||
strings.Contains(low, "is another process using it") ||
strings.Contains(low, "dpkg frontend lock")
}
// sanitizeHelperError strips absolute paths from helper diagnostics so the
// desktop UI never surfaces user home directories.
func sanitizeHelperError(err error) string {
if err == nil {
return "install failed"
}
msg := err.Error()
// Drop anything that looks like an absolute path segment.
fields := strings.Fields(msg)
for i, f := range fields {
if strings.HasPrefix(f, "/") {
fields[i] = "<path>"
}
}
out := strings.Join(fields, " ")
if out == "" {
return "install failed"
}
if len(out) > 240 {
out = out[:240]
}
return out
}
func writePhaseLine(phase string) {
// Single line, no user paths — desktop parses this while the helper runs.
fmt.Fprintf(os.Stderr, "%s%s\n", phasePrefix, phase)
}
func writeResultJSON(r helperResult) {
enc := json.NewEncoder(os.Stdout)
_ = enc.Encode(r)
}
// parsePhaseLine extracts a progress phase from a helper stderr line.
func parsePhaseLine(line string) (phase string, ok bool) {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, phasePrefix) {
return "", false
}
phase = strings.TrimSpace(strings.TrimPrefix(line, phasePrefix))
return phase, phase != ""
}
@@ -0,0 +1,596 @@
//go:build linux
package main
import (
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"aead.dev/minisign"
)
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
err := errors.New("/home/user/cache/foo.deb locked by /var/lib/dpkg/lock")
got := sanitizeHelperError(err)
if strings.Contains(got, "/home/") || strings.Contains(got, "/var/") {
t.Fatalf("path leaked: %q", got)
}
if !strings.Contains(got, "<path>") {
t.Fatalf("expected path redaction, got %q", got)
}
}
func TestIsPackageManagerBusy(t *testing.T) {
if !isPackageManagerBusy(errors.New("Could not get lock /var/lib/dpkg/lock-frontend")) {
t.Fatal("expected busy detection")
}
if isPackageManagerBusy(errors.New("dependency problems prevent configuration")) {
t.Fatal("dependency failure is not busy")
}
}
func TestAcceptDebIdentity(t *testing.T) {
cases := []struct {
name string
id debIdentity
goArch string
wantErr string
}{
{
name: "ok amd64",
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "amd64"},
goArch: "amd64",
},
{
name: "wrong package",
id: debIdentity{Package: "evil", Version: "1.2.3", Arch: "amd64"},
goArch: "amd64",
wantErr: "package name rejected",
},
{
name: "wrong arch",
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "arm64"},
goArch: "amd64",
wantErr: "package architecture rejected",
},
{
name: "arch all allowed",
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "all"},
goArch: "amd64",
},
{
name: "missing version",
id: debIdentity{Package: packageName, Version: "", Arch: "amd64"},
goArch: "amd64",
wantErr: "package version missing",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := acceptDebIdentity(tc.id, tc.goArch)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
return
}
if err == nil || err.Error() != tc.wantErr {
t.Fatalf("err = %v, want %q", err, tc.wantErr)
}
})
}
}
func TestAcceptVersionTransition(t *testing.T) {
tests := []struct {
name string
cmp int
candidate string
installed string
wantDowngrade bool
wantErr bool
}{
{name: "ordinary upgrade", cmp: 1, candidate: "1.3.0", installed: "1.2.0"},
{name: "equal rejected", cmp: 0, candidate: "1.2.0", installed: "1.2.0", wantErr: true},
{name: "preview to older stable", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1", wantDowngrade: true},
{name: "stable to older stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0", wantErr: true},
{name: "preview to older preview rejected", cmp: -1, candidate: "1.2.0~preview.1", installed: "1.3.0~preview.1", wantErr: true},
{name: "rc to stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~rc.1", wantErr: true},
{name: "internal preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1+internal", wantErr: true},
{name: "malformed stable rejected", cmp: -1, candidate: "01.2.0", installed: "1.3.0~preview.1", wantErr: true},
{name: "malformed preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.01", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowDowngrade, err := acceptVersionTransition(tt.cmp, tt.candidate, tt.installed)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if allowDowngrade != tt.wantDowngrade {
t.Fatalf("allowDowngrade = %v, want %v", allowDowngrade, tt.wantDowngrade)
}
})
}
}
func TestAptInstallArgvFixed(t *testing.T) {
argv := aptInstallArgv("/tmp/pkg.deb", false)
want := []string{
"/usr/bin/apt-get",
"install",
"--assume-yes",
"--only-upgrade",
"--no-remove",
"/tmp/pkg.deb",
}
if len(argv) != len(want) {
t.Fatalf("argv = %v", argv)
}
for i := range want {
if argv[i] != want[i] {
t.Fatalf("argv[%d] = %q, want %q", i, argv[i], want[i])
}
}
joined := strings.Join(argv, " ")
for _, bad := range []string{"--allow-unauthenticated", "--allow-downgrades", "--force-yes", "sh -c"} {
if strings.Contains(joined, bad) {
t.Fatalf("forbidden apt option present: %s in %v", bad, argv)
}
}
downgradeArgv := aptInstallArgv("/tmp/pkg.deb", true)
if strings.Count(strings.Join(downgradeArgv, " "), "--allow-downgrades") != 1 {
t.Fatalf("constrained downgrade argv = %v", downgradeArgv)
}
if downgradeArgv[len(downgradeArgv)-1] != "/tmp/pkg.deb" {
t.Fatalf("package path must remain the final fixed argument: %v", downgradeArgv)
}
}
func TestParsePhaseLine(t *testing.T) {
phase, ok := parsePhaseLine(phasePrefix + "installing")
if !ok || phase != "installing" {
t.Fatalf("got %q %v", phase, ok)
}
if _, ok := parsePhaseLine("noise"); ok {
t.Fatal("noise should not parse")
}
}
func TestCopyOwnedRegularFileRejectsSymlinkFailClosed(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "real.deb")
if err := os.WriteFile(target, []byte("deb"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link.deb")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
// Any UID — O_NOFOLLOW must fail before ownership is checked.
if err := copyOwnedRegularFile(link, dst, 0o600, os.Getuid(), maxInputBytes); err == nil {
t.Fatal("symlink must be rejected without fallback open")
}
}
func TestCopyOwnedRegularFileRejectsWrongOwner(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root; cannot construct a non-self-owned file cheaply")
}
dir := t.TempDir()
src := filepath.Join(dir, "in.deb")
if err := os.WriteFile(src, []byte("package-bytes"), 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
// Claim the file must be owned by root while the test user owns it.
if err := copyOwnedRegularFile(src, dst, 0o600, 0, maxInputBytes); err == nil {
t.Fatal("wrong owner must be rejected")
}
}
func TestCopyOwnedRegularFileRejectsOversize(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.deb")
if err := os.WriteFile(src, []byte("0123456789"), 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), 4); err == nil {
t.Fatal("oversize input must be rejected")
}
}
func TestCopyOwnedRegularFileCopiesMatchingOwner(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.deb")
payload := []byte("package-bytes")
if err := os.WriteFile(src, payload, 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "out.deb")
if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), maxInputBytes); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("copy mismatch: %q", got)
}
}
// --- runInstall table-driven coverage via injectable deps ---
type installProbe struct {
phases []string
results []helperResult
aptCalls []string
}
func baseFakeDeps(p *installProbe) installDeps {
return installDeps{
geteuid: func() int { return 0 },
getenv: func(k string) string {
if k == "PKEXEC_UID" {
return "1000"
}
return ""
},
mkTempDir: func() (string, error) {
return os.MkdirTemp("", "reasonix-helper-test-*")
},
removeAll: os.RemoveAll,
copyOwnedRegular: func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, mode)
},
readFile: os.ReadFile,
verify: func(data, sig []byte) error { return nil },
inspectDeb: func(path string) (debIdentity, error) {
return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
},
installedVersion: func() (string, error) { return "1.1.0", nil },
compareVersions: func(a, b string) (int, error) { return 1, nil },
aptInstall: func(pkgPath string, allowDowngrade bool) error {
p.aptCalls = append(p.aptCalls, pkgPath)
// Assert argv shape through the pure helper used by production.
argv := aptInstallArgv(pkgPath, allowDowngrade)
if argv[0] != aptGetPath || argv[1] != "install" {
return fmt.Errorf("bad argv: %v", argv)
}
return nil
},
verifyInstalled: func(want string) error {
if want != "1.2.0" {
return errors.New("installed version mismatch")
}
return nil
},
writePhase: func(phase string) { p.phases = append(p.phases, phase) },
writeResult: func(r helperResult) { p.results = append(p.results, r) },
goArch: "amd64",
maxInputBytes: maxInputBytes,
}
}
func writeInstallInputs(t *testing.T) (pkg, sig string) {
t.Helper()
dir := t.TempDir()
pkg = filepath.Join(dir, "Reasonix.deb")
sig = filepath.Join(dir, "Reasonix.deb.minisig")
if err := os.WriteFile(pkg, []byte("deb-payload"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sig, []byte("sig-payload"), 0o600); err != nil {
t.Fatal(err)
}
return pkg, sig
}
func TestRunInstallSuccessEmitsInstallingPhaseBeforeApt(t *testing.T) {
pkg, sig := writeInstallInputs(t)
var probe installProbe
d := baseFakeDeps(&probe)
aptStarted := false
d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
aptStarted = true
if allowDowngrade {
t.Fatal("ordinary upgrade unexpectedly enabled downgrades")
}
if len(probe.phases) == 0 || probe.phases[0] != "installing" {
t.Fatalf("phase installing must be emitted before apt; phases=%v", probe.phases)
}
probe.aptCalls = append(probe.aptCalls, pkgPath)
return nil
}
code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
if code != exitOK {
t.Fatalf("exit = %d, results=%+v", code, probe.results)
}
if !aptStarted || len(probe.aptCalls) != 1 {
t.Fatalf("apt not called: %+v", probe.aptCalls)
}
if len(probe.results) != 1 || !probe.results[0].OK || probe.results[0].Version != "1.2.0" {
t.Fatalf("result = %+v", probe.results)
}
}
func TestRunInstallTable(t *testing.T) {
pkg, sig := writeInstallInputs(t)
type tc struct {
name string
mutate func(*installDeps, *installProbe)
args []string
wantCode int
wantCodeStr string
wantPhase bool // installing emitted only on success path past validation
wantOK bool
}
cases := []tc{
{
name: "usage missing flags",
args: []string{"install"},
wantCode: exitUsage,
wantCodeStr: "usage",
},
{
name: "not root",
mutate: func(d *installDeps, _ *installProbe) {
d.geteuid = func() int { return 1000 }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitNotRoot,
wantCodeStr: "not_root",
},
{
name: "missing PKEXEC_UID",
mutate: func(d *installDeps, _ *installProbe) {
d.getenv = func(string) string { return "" }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitNotRoot,
wantCodeStr: "not_root",
},
{
name: "bad input copy",
mutate: func(d *installDeps, _ *installProbe) {
d.copyOwnedRegular = func(string, string, os.FileMode, int, int64) error {
return errors.New("owner mismatch")
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitBadInput,
wantCodeStr: "bad_input",
},
{
name: "verify failed",
mutate: func(d *installDeps, _ *installProbe) {
d.verify = func([]byte, []byte) error { return errors.New("bad sig") }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitVerifyFailed,
wantCodeStr: "verify_failed",
},
{
name: "wrong package name",
mutate: func(d *installDeps, _ *installProbe) {
d.inspectDeb = func(string) (debIdentity, error) {
return debIdentity{Package: "other", Version: "2.0", Arch: "amd64"}, nil
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "wrong architecture",
mutate: func(d *installDeps, _ *installProbe) {
d.inspectDeb = func(string) (debIdentity, error) {
return debIdentity{Package: packageName, Version: "2.0", Arch: "arm64"}, nil
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "same version rejected",
mutate: func(d *installDeps, _ *installProbe) {
d.compareVersions = func(a, b string) (int, error) { return 0, nil }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "downgrade rejected",
mutate: func(d *installDeps, _ *installProbe) {
d.compareVersions = func(a, b string) (int, error) { return -1, nil }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPackageRejected,
wantCodeStr: "package_rejected",
},
{
name: "preview to stable downgrade allowed",
mutate: func(d *installDeps, p *installProbe) {
d.inspectDeb = func(string) (debIdentity, error) {
return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
}
d.installedVersion = func() (string, error) { return "1.3.0~preview.1", nil }
d.compareVersions = func(a, b string) (int, error) { return -1, nil }
d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
if !allowDowngrade {
return errors.New("preview-to-stable downgrade flag missing")
}
p.aptCalls = append(p.aptCalls, pkgPath)
return nil
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitOK,
wantPhase: true,
wantOK: true,
},
{
name: "apt busy",
mutate: func(d *installDeps, _ *installProbe) {
d.aptInstall = func(string, bool) error {
return errors.New("Could not get lock /var/lib/dpkg/lock-frontend")
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitBusy,
wantCodeStr: "package_manager_busy",
wantPhase: true,
},
{
name: "apt non-zero",
mutate: func(d *installDeps, _ *installProbe) {
d.aptInstall = func(string, bool) error {
return errors.New("E: Sub-process /usr/bin/dpkg returned an error code (1)")
}
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitInstallFailed,
wantCodeStr: "install_failed",
wantPhase: true,
},
{
name: "post install version mismatch",
mutate: func(d *installDeps, _ *installProbe) {
d.verifyInstalled = func(string) error { return errors.New("installed version mismatch") }
},
args: []string{"install", "--package", pkg, "--signature", sig},
wantCode: exitPostVerify,
wantCodeStr: "package_verify_failed",
wantPhase: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var probe installProbe
d := baseFakeDeps(&probe)
if tc.mutate != nil {
tc.mutate(&d, &probe)
}
code := runWith(d, tc.args)
if code != tc.wantCode {
t.Fatalf("exit = %d, want %d; results=%+v", code, tc.wantCode, probe.results)
}
if len(probe.results) == 0 {
t.Fatal("expected a written result")
}
last := probe.results[len(probe.results)-1]
if last.OK != tc.wantOK {
t.Fatalf("result OK = %v, want %v: %+v", last.OK, tc.wantOK, last)
}
if tc.wantCodeStr != "" && last.Code != tc.wantCodeStr {
t.Fatalf("code = %q, want %q", last.Code, tc.wantCodeStr)
}
if tc.wantPhase {
if len(probe.phases) == 0 || probe.phases[0] != "installing" {
t.Fatalf("expected installing phase before failure after validation, got %v", probe.phases)
}
} else if len(probe.phases) > 0 {
// Validation failures must not claim apt started.
t.Fatalf("unexpected phase before validation failure: %v", probe.phases)
}
})
}
}
func TestRunInstallCleansTempDirOnFailure(t *testing.T) {
pkg, sig := writeInstallInputs(t)
var probe installProbe
d := baseFakeDeps(&probe)
var created, removed string
d.mkTempDir = func() (string, error) {
dir, err := os.MkdirTemp("", "reasonix-helper-test-*")
created = dir
return dir, err
}
d.removeAll = func(path string) error {
removed = path
return os.RemoveAll(path)
}
d.verify = func([]byte, []byte) error { return errors.New("fail after tmp") }
code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
if code != exitVerifyFailed {
t.Fatalf("exit = %d", code)
}
if created == "" || removed != created {
t.Fatalf("temp dir not cleaned: created=%q removed=%q", created, removed)
}
if _, err := os.Stat(created); !os.IsNotExist(err) {
t.Fatalf("temp dir still on disk: %v", err)
}
}
func TestRunInstallRejectsUnknownCommand(t *testing.T) {
var probe installProbe
d := baseFakeDeps(&probe)
code := runWith(d, []string{"upgrade"})
if code != exitUsage {
t.Fatalf("exit = %d", code)
}
}
// TestMinisignRejectsWrongKeyAndTamper pins that the shared verify path used by
// the helper rejects throwaway-key signatures and tampered payloads. Production
// uses update.Verify with the embedded key; here we exercise the same minisign
// library contract the helper depends on.
func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
pub, priv, err := minisign.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
data := []byte("artifact")
sig := minisign.Sign(priv, data)
if !minisign.Verify(pub, data, sig) {
t.Fatal("genuine signature under matching key must verify")
}
if minisign.Verify(pub, []byte("tampered"), sig) {
t.Fatal("tampered payload must not verify")
}
otherPub, _, err := minisign.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
if minisign.Verify(otherPub, data, sig) {
t.Fatal("signature under a different public key must not verify")
}
// Garbage signature must fail under the matching public key.
if minisign.Verify(pub, data, []byte("not-a-sig")) {
t.Fatal("garbage signature must not verify")
}
// Wire the same contract into installDeps.verify so a wrong-key path fails
// runInstall with exitVerifyFailed.
var probe installProbe
d := baseFakeDeps(&probe)
d.verify = func(payload, signature []byte) error {
if !minisign.Verify(pub, payload, signature) {
return errors.New("signature verification failed")
}
return nil
}
pkg, sigPath := writeInstallInputs(t)
// Input files contain "deb-payload"/"sig-payload", not a real signature.
code := runWith(d, []string{"install", "--package", pkg, "--signature", sigPath})
if code != exitVerifyFailed {
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package main
@@ -8,6 +8,6 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
os.Exit(2)
}
+458 -17
View File
@@ -3,36 +3,58 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/windows"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installDir, relaunch, toVersion string
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -41,35 +63,164 @@ func run(args []string) int {
logger.Print("missing --installer")
return 2
}
if !validSHA256(installerSHA256) {
logger.Print("missing or invalid --installer-sha256")
return 2
}
if toVersion == "" {
logger.Print("missing --to-version")
return 2
}
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
if installDir == "" {
logger.Print("missing --install-dir")
return 2
}
if parentPID != 0 {
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if err := runInstaller(installer, installDir); err != nil {
logger.Printf("run installer: %v", err)
// The desktop already exited cleanly, so nothing would notice this
// failure: record it and relaunch through Guard, which rolls the
// release unit back on startup (the helper itself runs from the cache
// directory, outside the validated install, and must not restore
// binaries directly).
if markErr := repair.MarkUpdateApplyFailed(toVersion, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunch(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install: %v", relaunchErr)
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
claimed, releaseClaim, err := claimPendingFileUpdateFn(
toVersion,
createdAt,
transactionID,
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
}
}
return 1
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind update installer: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create update staging: %v", err)
return recoverUnstarted()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind update staging: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged installer: %v", err)
return recoverUnstarted()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract installer payload: %v", installerErr)
return recoverUnstarted()
}
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
if err != nil {
logger.Printf("publish staged release unit: %v", err)
if !publishStarted {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
}
if relaunch != "" {
if err := startRelaunch(relaunch, installDir); err != nil {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -77,6 +228,253 @@ func run(args []string) int {
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
if !validSHA256(expectedSHA256) {
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
}
sourceInfo, err := os.Lstat(sourcePath)
if err != nil {
return "", nil, err
}
if !sourceInfo.Mode().IsRegular() {
return "", nil, fmt.Errorf("installer is not a regular file")
}
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
if err != nil {
return "", nil, err
}
owner, err := os.Lstat(dir)
if err != nil {
return "", nil, err
}
cleanup := func() error {
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
}
fail := func(err error) (string, func() error, error) {
_ = cleanup()
return "", nil, err
}
source, err := os.Open(sourcePath)
if err != nil {
return fail(err)
}
defer source.Close()
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
if err != nil {
return fail(err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
syncErr := staged.Sync()
closeErr := staged.Close()
switch {
case copyErr != nil:
return fail(copyErr)
case syncErr != nil:
return fail(syncErr)
case closeErr != nil:
return fail(closeErr)
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
}
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
return fail(err)
}
return stagedPath, cleanup, nil
}
func verifyInstallerSHA256(path, expectedSHA256 string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("installer is not a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hash := sha256.New()
if _, err := io.Copy(hash, f); err != nil {
return err
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fmt.Errorf("installer SHA-256 mismatch")
}
return nil
}
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
if !validSHA256(expectedSHA256) {
return nil, fmt.Errorf("installer SHA-256 is invalid")
}
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathUTF16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
fail := func(err error) (func(), error) {
_ = file.Close()
return nil, err
}
info, err := file.Stat()
if err != nil {
return fail(err)
}
if !info.Mode().IsRegular() {
return fail(fmt.Errorf("installer is not a regular file"))
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fail(err)
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fail(fmt.Errorf("installer SHA-256 mismatch"))
}
var once sync.Once
return func() {
once.Do(func() { _ = file.Close() })
}, nil
}
func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
if installDir == "" {
return nil
}
names := []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
"Reasonix.exe",
}
paths := make([]string, 0, len(names))
for _, name := range names {
paths = append(paths, filepath.Join(installDir, name))
}
return paths
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
@@ -117,12 +515,55 @@ func waitForProcessExit(pid uint32, timeout time.Duration) error {
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
// Keep the helper itself hidden, but let the NSIS update-progress window be
// visible. The dedicated /REASONIXUPDATE mode prevents directory changes and
// closes automatically after the update finishes.
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
// performs every live replacement through the claimed transaction.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
return cmd.Run()
}
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
if path == "" || owner == nil || !owner.IsDir() {
return fmt.Errorf("Windows update cleanup identity is incomplete")
}
for attempt := 0; attempt < 16; attempt++ {
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
from, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(cleanup)
if err != nil {
return err
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
if os.IsNotExist(err) {
return nil
}
if os.IsExist(err) {
continue
}
return err
}
actual, err := os.Lstat(cleanup)
if err != nil {
return err
}
if !os.SameFile(owner, actual) {
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
restoreTo, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
}
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
}
return fmt.Errorf("Windows update staging changed before cleanup")
}
return os.RemoveAll(cleanup)
}
return fmt.Errorf("cannot allocate Windows update cleanup path")
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
+539 -2
View File
@@ -2,10 +2,547 @@
package main
import "testing"
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(source, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
expected := hex.EncodeToString(sum[:])
staged, cleanup, err := stageVerifiedInstaller(source, expected)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cleanup() })
if staged == source {
t.Fatal("installer was not isolated from the mutable cache path")
}
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err != nil {
t.Fatalf("isolated installer changed with source: %v", err)
}
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
t.Fatalf("staged installer = %q, %v", got, err)
}
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err == nil {
t.Fatal("tampered staged installer passed final verification")
}
}
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
source := filepath.Join(t.TempDir(), "installer.exe")
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
t.Fatal(err)
}
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
if cleanup != nil {
_ = cleanup()
}
t.Fatal("installer bytes different from desktop verification were accepted")
}
}
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
if code := run([]string{"--installer", `C:\Temp\Reasonix-installer.exe`}); code != 2 {
if code := run([]string{
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", testInstallerSHA256,
}); code != 2 {
t.Fatalf("run without --to-version = %d, want 2", code)
}
}
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := t.TempDir()
seed := t.TempDir()
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error {
events = append(events, "wait")
return nil
}
runInstallerFn = func(string, string) error {
events = append(events, "installer")
return nil
}
startRelaunchFn = func(string, string) error {
events = append(events, "relaunch")
return nil
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
return tx, nil
}
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
events = append(events, "stage-installer:"+sha256)
return path + ".claimed", func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
events = append(events, "claim-installer")
return func() { events = append(events, "release-installer") }, nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
events[2] != "stage-installer:"+testInstallerSHA256 ||
events[3] != "claim-installer" || events[4] != "installer" ||
events[5] != "release-installer" || events[6] != "publish" ||
events[7] != "record-installed" || events[8] != "relaunch" ||
events[9] != "release" {
t.Fatalf("handoff events = %#v", events)
}
}
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
})
waitForProcessExitFn = func(uint32, time.Duration) error {
return errors.New("parent still running")
}
claimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
claimed = true
return &repair.UpdateTransaction{}, func() {}, nil
}
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if claimed || installerRan {
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if _, ok := repair.ReadUpdateApplyFailure(); ok {
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
}
}
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return nil }
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
}
failure, ok := repair.ReadUpdateApplyFailure()
if !ok || failure.UpdateTransactionID != transactionID {
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
}
}
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
installDir := t.TempDir()
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalLstatStaging := lstatUpdateStagingFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
lstatUpdateStagingFn = originalLstatStaging
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
return nil, errors.New("staging identity unavailable")
}
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", claimed.ToVersion,
"--created-at", claimed.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(claimed),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if installerRan {
t.Fatal("installer ran without a bound staging directory")
}
if !relaunched {
t.Fatal("helper did not relaunch after staging identity failure")
}
if releases == 0 {
t.Fatal("helper did not release the update claim")
}
}
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(path, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
release()
t.Fatal("installer remained writable while execution claim was held")
}
if err := os.Rename(path, path+".replaced"); err == nil {
release()
t.Fatal("installer remained renameable while execution claim was held")
}
release()
release()
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
t.Fatalf("installer remained frozen after claim release: %v", err)
}
}
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
path := filepath.Join(t.TempDir(), "installer.exe")
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
release()
t.Fatal("installer with the wrong SHA-256 received an execution claim")
}
}
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/desktop/internal/update"
"reasonix/internal/repair"
)
const maxWindowsPayloadMetadataSize = 64 << 10
var verifyWindowsPayloadManifestFn = update.Verify
type stagedFileUpdateMember struct {
targetPath string
content []byte
mode os.FileMode
}
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
// before any live release-unit member is moved. An existing Reasonix.exe is the
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
// installed package that did not have the alias remains unchanged.
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
}
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
}
info, err := os.Lstat(stagingDir)
if err != nil {
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
}
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
if err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
contents := make(map[string][]byte)
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
seenTargets := make(map[string]struct{}, len(claimed.Files))
for _, file := range claimed.Files {
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
targetKey := strings.ToLower(targetPath)
if targetPath == "" || targetPath == "." {
return nil, fmt.Errorf("load staged release unit: target path is invalid")
}
if _, ok := seenTargets[targetKey]; ok {
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
}
seenTargets[targetKey] = struct{}{}
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
continue
}
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
if err != nil {
return nil, err
}
sourcePath := filepath.Join(stagingDir, sourceName)
content, ok := contents[sourceName]
if !ok {
sourceInfo, statErr := os.Lstat(sourcePath)
if statErr != nil {
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
}
if !sourceInfo.Mode().IsRegular() {
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
}
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
if err != nil {
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
}
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
}
contents[sourceName] = content
}
members = append(members, stagedFileUpdateMember{
targetPath: targetPath,
content: content,
mode: 0o700,
})
}
// Publish the running desktop last. If an earlier member fails, the old
// desktop remains the executable entry point that can report/retry recovery.
sort.SliceStable(members, func(i, j int) bool {
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
return !iPrimary && jPrimary
})
return members, nil
}
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
if claimed == nil ||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
return fmt.Errorf("claimed release unit primary executable is invalid")
}
required := map[string]bool{
"reasonix-desktop.exe": false,
"reasonix-guard.exe": false,
"reasonix-launcher.exe": false,
"reasonix-update-helper.exe": false,
"reasonix-cli.exe": false,
"reasonix.exe": false,
}
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
for _, file := range claimed.Files {
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
if target == "" || target == "." ||
!strings.EqualFold(filepath.Dir(target), installDir) {
return fmt.Errorf("claimed release unit target is outside the installation directory")
}
name := strings.ToLower(filepath.Base(target))
seen, ok := required[name]
if !ok {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
if seen {
return fmt.Errorf("claimed release unit contains a duplicate target")
}
required[name] = true
}
for name, seen := range required {
if !seen {
return fmt.Errorf("claimed release unit omits %s", name)
}
}
if len(claimed.Files) != len(required) {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
return nil
}
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest: %w", err)
}
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
}
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
return nil, fmt.Errorf("verify signed release manifest: %w", err)
}
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
if err != nil {
return nil, err
}
return hashes, nil
}
func readWindowsPayloadMetadata(path string) ([]byte, error) {
pathInfo, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !pathInfo.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
}
if !os.SameFile(pathInfo, info) {
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
}
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
if err != nil {
return nil, err
}
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
}
return data, nil
}
func windowsStagedSourceName(targetBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(targetBase)) {
case "reasonix-desktop.exe":
return "reasonix-desktop.exe", nil
case "reasonix-guard.exe":
return "reasonix-guard.exe", nil
case "reasonix-launcher.exe":
return "reasonix-launcher.exe", nil
case "reasonix-update-helper.exe":
return "reasonix-update-helper.exe", nil
case "reasonix-cli.exe":
return "reasonix-cli.exe", nil
case "reasonix.exe":
return "reasonix-launcher.exe", nil
default:
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
}
}
func publishLoadedFileUpdateReleaseUnit(
claimed *repair.UpdateTransaction,
members []stagedFileUpdateMember,
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
) ([]repair.FileUpdateInstallReceipt, error) {
if publish == nil || len(members) == 0 {
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
}
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
for _, member := range members {
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
if err != nil {
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
}
receipts = append(receipts, receipt)
}
return receipts, nil
}
@@ -0,0 +1,376 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/desktop/internal/update"
"reasonix/internal/repair"
)
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
staging := t.TempDir()
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := t.TempDir()
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
t.Fatalf("last published member = %q, want desktop", got)
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, filepath.Base(target)+"="+string(content))
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
})
if err != nil {
t.Fatal(err)
}
if len(receipts) != len(members) {
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
}
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
}
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
t.Fatalf("publish order = %v", published)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
staging := t.TempDir()
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
t.Fatal(err)
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := t.TempDir()
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
},
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("incomplete staged release unit was accepted")
}
}
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
for i, file := range complete.Files {
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "omits") {
t.Fatalf("missing target error = %v", err)
}
})
}
t.Run("extra", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "unexpected") {
t.Fatalf("extra target error = %v", err)
}
})
t.Run("duplicate", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate target error = %v", err)
}
})
t.Run("outside", func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
claimed.Files[1].TargetPath = filepath.Join(t.TempDir(), filepath.Base(claimed.Files[1].TargetPath))
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "outside") {
t.Fatalf("outside target error = %v", err)
}
})
t.Run("primary", func(t *testing.T) {
claimed := *complete
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "primary") {
t.Fatalf("primary target error = %v", err)
}
})
}
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
staging := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
content := "payload:" + name
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := t.TempDir()
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
for _, member := range members {
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
t.Fatalf("missing portable alias was added to publish set: %+v", members)
}
}
}
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
claimed := &repair.UpdateTransaction{TargetKind: "file"}
members := []stagedFileUpdateMember{
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, target)
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
})
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
t.Fatalf("publish error = %v", err)
}
if len(published) != 1 {
t.Fatalf("published members = %v, want one attempted member", published)
}
if len(receipts) != 0 {
t.Fatalf("failed first publish returned receipts: %+v", receipts)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
return nil, errors.New("payload signature rejected")
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("unsigned staged payload must be rejected before publication")
}
}
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
staging := t.TempDir()
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
var verified []string
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
verified = append(verified, filepath.Base(path))
return os.ReadFile(path)
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
t.Fatal(err)
}
if len(verified) != len(update.WindowsPayloadFileNames()) {
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
t.Fatalf("missing signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "verify signed release manifest") {
t.Fatalf("bad signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v3")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("release version drift error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
t.Fatalf("release member drift error = %v", err)
}
}
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
t.Helper()
staging := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
installDir := t.TempDir()
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
for _, name := range update.WindowsPayloadFileNames() {
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
}
files = append(files, repair.UpdateTransactionFile{
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
MissingBefore: true,
})
return staging, &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: version,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: files,
}
}
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
t.Helper()
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
content, err := os.ReadFile(filepath.Join(staging, name))
if os.IsNotExist(err) {
content = []byte("missing:" + name)
} else if err != nil {
t.Fatal(err)
}
hashes[name] = update.WindowsPayloadSHA256(content)
}
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
t.Fatal(err)
}
}
func acceptWindowsPayloadManifestForTest(t *testing.T) {
t.Helper()
original := verifyWindowsPayloadManifestFn
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
if string(signature) != "test signature" {
return errors.New("unexpected test signature")
}
return nil
}
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
}
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
t.Helper()
writeWindowsPayloadManifestForTest(t, staging, version)
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
}
func useUnverifiedPayloadReaderForTest(t *testing.T) {
t.Helper()
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = os.ReadFile
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
}
@@ -0,0 +1,118 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -0,0 +1,129 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := t.TempDir()
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ import (
"strings"
)
// crash_app.go is the crash/feedback/performance reporting surface. Reports are
// sent only on an explicit user click in the frontend UI — never automatically.
// crash_app.go is the crash/feedback/performance reporting surface. Frontend
// reports are sent on an explicit user click. Native fatal/lifecycle reports are
// queued locally and sent on a later launch only when desktop telemetry is on.
var crashEndpoint = "https://crash.reasonix.io/v1/report"
+270
View File
@@ -0,0 +1,270 @@
package main
import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"reasonix/internal/repair"
)
func TestParseWebView2ProcessFailure(t *testing.T) {
kind, ok := parseWebView2ProcessFailure("windows | WebVie2wProcess failed with kind 6")
if !ok || kind != 6 {
t.Fatalf("kind=%d ok=%v", kind, ok)
}
if _, ok := parseWebView2ProcessFailure("unrelated failure"); ok {
t.Fatal("unrelated log message matched WebView2 failure")
}
}
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
report := webView2ProcessFailureReport(2)
if report.Source != "webview2.process" || report.Label != "windows.webview2.process_failed" {
t.Fatalf("report = %+v", report)
}
if report.FingerprintHint != "windows.webview2.render_process_unresponsive" {
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
}
}
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
report := previousRunReport(repair.PreviousRunObservation{
Abnormal: true,
Phase: "healthy",
Version: "v2",
InstallProfile: "installer",
UpdateFrom: "v1",
UpdateTo: "v2",
UptimeBucket: "m_2_10",
})
if report.Source != "native.lifecycle" || report.Label != "desktop.abnormal_exit" {
t.Fatalf("report = %+v", report)
}
if !strings.Contains(report.Message, "uptime bucket: m_2_10") {
t.Fatalf("message missing bounded uptime: %q", report.Message)
}
}
func TestCapturePreviousFatalCrashQueuesAndRemovesRawDump(t *testing.T) {
resetFatalCrashArtifacts(t)
const crashedPID = 424242
raw := "fatal error: concurrent map writes\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
path := fatalCrashPathForPID(crashedPID)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("raw fatal dump was not removed: %v", err)
}
report, ok := readPending(t)
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
t.Fatalf("queued report = %+v ok=%v", report, ok)
}
if strings.Contains(report.Stack, "/home/alice") {
t.Fatalf("fatal stack leaked home path: %q", report.Stack)
}
}
func TestSanitizeFatalRuntimeDumpRemovesPanicValue(t *testing.T) {
got := sanitizeFatalRuntimeDump("panic: private prompt text\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n")
if strings.Contains(got, "private prompt text") || strings.Contains(got, "/home/alice") {
t.Fatalf("fatal dump leaked user-controlled text: %q", got)
}
if !strings.Contains(got, "panic: [redacted panic value]") || !strings.Contains(got, "main.go:12") {
t.Fatalf("fatal dump lost diagnostic structure: %q", got)
}
}
func TestCapturePreviousFatalCrashSkipsRuntimeDuplicateOfStructuredPanic(t *testing.T) {
resetFatalCrashArtifacts(t)
const crashedPID = 424243
path := fatalCrashPathForPID(crashedPID)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("panic: duplicate\n\ngoroutine 1 [running]:\nmain.run()\n"), 0o600); err != nil {
t.Fatal(err)
}
markFatalCrashCoveredForPID(crashedPID)
capturePreviousFatalCrash()
if _, ok := readPending(t); ok {
t.Fatal("runtime duplicate was queued despite structured panic marker")
}
}
func TestCapturePreviousFatalCrashDoesNotTouchLiveOwner(t *testing.T) {
resetFatalCrashArtifacts(t)
const livePID = 424244
path := fatalCrashPathForPID(livePID)
raw := []byte("fatal error: still being written\n\ngoroutine 1 [running]:\n")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
oldProcessAlive := fatalCrashProcessAlive
fatalCrashProcessAlive = func(pid int) bool { return pid == livePID }
t.Cleanup(func() { fatalCrashProcessAlive = oldProcessAlive })
capturePreviousFatalCrash()
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("live owner's fatal file was removed: %v", err)
}
if string(got) != string(raw) {
t.Fatalf("live owner's fatal file changed: got %q want %q", got, raw)
}
if _, ok := readPending(t); ok {
t.Fatal("live owner's partial fatal output was queued")
}
}
func TestCapturePreviousFatalCrashKeepsEmptyLegacyFile(t *testing.T) {
resetFatalCrashArtifacts(t)
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyFatalCrashPath(), nil, 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(legacyFatalCrashPath()); err != nil {
t.Fatalf("empty legacy fatal file may belong to a live older process: %v", err)
}
}
func TestCapturePreviousFatalCrashMigratesLegacyDump(t *testing.T) {
resetFatalCrashArtifacts(t)
raw := "fatal error: legacy crash\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacyFatalCrashPath(), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
capturePreviousFatalCrash()
if _, err := os.Stat(legacyFatalCrashPath()); !os.IsNotExist(err) {
t.Fatalf("captured legacy fatal dump was not removed: %v", err)
}
report, ok := readPending(t)
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
t.Fatalf("legacy fatal dump was not migrated: report=%+v ok=%v", report, ok)
}
}
func TestAwaitWindowRestoreRequiresNativeConfirmation(t *testing.T) {
ticks := make(chan time.Time)
deadline := make(chan time.Time, 1)
deadline <- time.Now()
if awaitWindowRestoreConfirmation(func() bool { return false }, ticks, deadline) {
t.Fatal("an enqueued show request without native confirmation was treated as restored")
}
}
func TestAwaitWindowRestoreAcceptsConfirmationAfterTick(t *testing.T) {
ticks := make(chan time.Time, 1)
deadline := make(chan time.Time)
var confirmed atomic.Bool
result := make(chan bool, 1)
go func() {
result <- awaitWindowRestoreConfirmation(confirmed.Load, ticks, deadline)
}()
confirmed.Store(true)
ticks <- time.Now()
if !<-result {
t.Fatal("native window confirmation was not accepted")
}
}
func TestSupersededWindowRestoreDoesNotRemoveLatestJournal(t *testing.T) {
path := windowRestoreStatePath()
_ = os.Remove(path)
t.Cleanup(func() { _ = os.Remove(path) })
oldSequence := windowRestoreSequence.Load()
t.Cleanup(func() { windowRestoreSequence.Store(oldSequence) })
latest := windowRestoreState{
SchemaVersion: windowRestoreStateVersion,
PID: os.Getpid(),
AttemptID: 2,
Source: "tray",
StartedAt: "2026-07-24T08:01:00Z",
}
if !writeWindowRestoreState(latest) {
t.Fatal("write latest window restore state")
}
windowRestoreSequence.Store(latest.AttemptID)
NewApp().completeWindowRestoreAttempt(1, windowRestoreState{AttemptID: 1}, true)
got, err := readWindowRestoreState()
if err != nil {
t.Fatalf("latest journal was removed by superseded attempt: %v", err)
}
if got != latest {
t.Fatalf("latest journal changed: got %+v want %+v", got, latest)
}
}
func resetFatalCrashArtifacts(t *testing.T) {
t.Helper()
oldProcessAlive := fatalCrashProcessAlive
fatalCrashProcessAlive = func(int) bool { return false }
removeAllPendingCrashes()
_ = os.Remove(legacyFatalCrashPath())
_ = os.Remove(legacyFatalCrashCoveredPath())
_ = os.RemoveAll(fatalCrashDir())
t.Cleanup(func() {
removeAllPendingCrashes()
_ = os.Remove(legacyFatalCrashPath())
_ = os.Remove(legacyFatalCrashCoveredPath())
_ = os.RemoveAll(fatalCrashDir())
fatalCrashProcessAlive = oldProcessAlive
})
}
func TestWindowRestoreFailureReportSeparatesTimeoutAndSource(t *testing.T) {
report := windowRestoreFailureReport("timeout", "second_instance", "2026-07-24T08:00:00Z")
if report.Label != "windows.window_restore.timeout" || report.TopFrame != "windows.window_restore.second_instance" {
t.Fatalf("report = %+v", report)
}
}
func TestWriteWindowRestoreStateCreatesReadableJournal(t *testing.T) {
path := windowRestoreStatePath()
_ = os.Remove(path)
t.Cleanup(func() { _ = os.Remove(path) })
want := windowRestoreState{
SchemaVersion: windowRestoreStateVersion,
PID: os.Getpid(),
Source: "tray",
StartedAt: "2026-07-24T08:00:00Z",
}
if !writeWindowRestoreState(want) {
t.Fatal("writeWindowRestoreState returned false")
}
got, err := readWindowRestoreState()
if err != nil {
t.Fatalf("readWindowRestoreState: %v", err)
}
if got != want {
t.Fatalf("journal = %+v, want %+v", got, want)
}
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"io"
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"time"
"reasonix/internal/config"
)
const (
fatalCrashDirName = "crash-fatal"
fatalCrashLogSuffix = ".log"
fatalCrashCoveredSuffix = ".covered"
legacyFatalCrashFile = "crash-fatal.log"
legacyFatalCrashCoveredFile = "crash-fatal-covered"
)
var fatalCrashProcessAlive = desktopProcessAlive
func fatalCrashDir() string {
return filepath.Join(config.MemoryUserDir(), fatalCrashDirName)
}
func fatalCrashPath() string {
return fatalCrashPathForPID(os.Getpid())
}
func fatalCrashCoveredPath() string {
return fatalCrashCoveredPathForPID(os.Getpid())
}
func fatalCrashPathForPID(pid int) string {
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashLogSuffix)
}
func fatalCrashCoveredPathForPID(pid int) string {
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashCoveredSuffix)
}
func legacyFatalCrashPath() string {
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashFile)
}
func legacyFatalCrashCoveredPath() string {
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashCoveredFile)
}
func markFatalCrashCovered() {
markFatalCrashCoveredForPID(os.Getpid())
}
func markFatalCrashCoveredForPID(pid int) {
path := fatalCrashCoveredPathForPID(pid)
if os.MkdirAll(filepath.Dir(path), 0o700) == nil {
_ = os.WriteFile(path, []byte("structured\n"), 0o600)
}
}
// capturePreviousFatalCrash converts runtime.SetCrashOutput dumps from dead
// processes into the normal scrubbed queue. Per-PID files keep a routine second
// launch from truncating or unlinking the running primary process's dump.
func capturePreviousFatalCrash() {
// Preserve compatibility with the single-file format used by older builds.
// An empty legacy file may still be owned by a running older process, so it
// must be left untouched until it contains a completed crash dump.
captureFatalCrashFile(legacyFatalCrashPath(), legacyFatalCrashCoveredPath(), false)
entries, err := os.ReadDir(fatalCrashDir())
if err != nil {
return
}
for _, entry := range entries {
pid, ok := fatalCrashPID(entry.Name())
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
continue
}
captureFatalCrashFile(
filepath.Join(fatalCrashDir(), entry.Name()),
fatalCrashCoveredPathForPID(pid),
true,
)
}
for _, entry := range entries {
pid, ok := fatalCrashCoveredPID(entry.Name())
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
continue
}
if _, err := os.Stat(fatalCrashPathForPID(pid)); os.IsNotExist(err) {
_ = os.Remove(filepath.Join(fatalCrashDir(), entry.Name()))
}
}
// Best effort: succeeds only when no live/current process artifacts remain.
_ = os.Remove(fatalCrashDir())
}
func fatalCrashPID(name string) (int, bool) {
return fatalCrashPIDWithSuffix(name, fatalCrashLogSuffix)
}
func fatalCrashCoveredPID(name string) (int, bool) {
return fatalCrashPIDWithSuffix(name, fatalCrashCoveredSuffix)
}
func fatalCrashPIDWithSuffix(name, suffix string) (int, bool) {
if !strings.HasSuffix(name, suffix) {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSuffix(name, suffix))
return pid, err == nil && pid > 0
}
func captureFatalCrashFile(path, coveredPath string, removeEmpty bool) {
f, err := os.Open(path)
if err != nil {
return
}
occurredAt := time.Now().UTC()
if info, statErr := f.Stat(); statErr == nil {
occurredAt = info.ModTime().UTC()
}
raw, readErr := io.ReadAll(io.LimitReader(f, maxCrashStackBytes+1))
_ = f.Close()
if readErr != nil || len(strings.TrimSpace(string(raw))) == 0 {
if removeEmpty {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
}
return
}
if _, err := os.Stat(coveredPath); err == nil {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
return
}
stack := sanitizeFatalRuntimeDump(string(raw))
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go.runtime"
report.Label = "go.fatal"
report.ErrorType = "GoRuntimeFatal"
report.ErrorMessage = "Go runtime terminated the desktop process."
report.Stack = stack
report.TopFrame = topFrameFromStack(stack)
report.FingerprintHint = "go.runtime.fatal"
report.OccurredAt = occurredAt.Format(time.RFC3339)
report.Message = sanitizeCrashText("[go.runtime.fatal]\n\n"+stack, maxCrashDetailBytes)
if writePendingReport(report, true) {
_ = os.Remove(coveredPath)
_ = os.Remove(path)
}
}
// sanitizeFatalRuntimeDump removes panic values and preamble text that could
// originate in user-controlled errors, while retaining runtime classification
// and symbolized goroutine stacks for diagnosis.
func sanitizeFatalRuntimeDump(raw string) string {
lines := strings.Split(raw, "\n")
classification := "runtime crash output"
stackStart := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "fatal error:"):
classification = sanitizeCrashText(trimmed, 256)
case strings.HasPrefix(trimmed, "panic:"):
classification = "panic: [redacted panic value]"
}
if strings.HasPrefix(trimmed, "goroutine ") {
stackStart = i
break
}
}
stack := ""
if stackStart >= 0 {
stack = strings.Join(lines[stackStart:], "\n")
}
return sanitizeCrashText(classification+"\n\n"+stack, maxCrashStackBytes)
}
// installFatalCrashOutput asks the Go runtime to mirror unrecovered panics and
// fatal runtime errors to a durable file. The runtime duplicates the descriptor,
// so the file may be closed after SetCrashOutput returns.
func installFatalCrashOutput() {
path := fatalCrashPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return
}
if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil {
_ = f.Close()
_ = os.Remove(path)
return
}
_ = f.Close()
}
+89 -27
View File
@@ -6,6 +6,11 @@ import (
"os"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"reasonix/internal/config"
)
@@ -16,12 +21,25 @@ import (
// otherwise never surface a single report. The resend is gated on the same
// desktop.telemetry opt-out as the launch ping.
const pendingCrashFile = "crash-pending.json"
const (
pendingCrashFile = "crash-pending.json" // legacy single-report path
pendingCrashQueueDir = "crash-pending"
maxPendingCrashes = 10
)
var (
pendingCrashMu sync.Mutex
pendingCrashSequence atomic.Uint64
)
func pendingCrashPath() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
}
func pendingCrashDir() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
}
// recoverToPending records a panicking goroutine to the pending-crash file and
// re-raises, so the process still crashes exactly as before — the stack is now
// shipped next launch instead of lost.
@@ -36,44 +54,84 @@ func (a *App) recoverToPending(site string) {
func writePendingCrash(site string, r any, stack []byte) {
stackText := string(stack)
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s: %v\n\n%s", site, r, stackText), maxCrashDetailBytes)
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go"
report.Label = sanitizeCrashField(site, 64)
report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
report.ErrorMessage = sanitizeCrashText(fmt.Sprint(r), maxCrashFieldBytes)
report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
_ = writePendingReport(report, true)
if writePendingReport(report, true) {
markFatalCrashCovered()
}
}
func writePendingReport(report crashReport, overwrite bool) bool {
_ = overwrite // retained for source compatibility; the queue never overwrites.
body, err := json.Marshal(report)
if err != nil {
return false
}
path := pendingCrashPath()
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
dir := pendingCrashDir()
if os.MkdirAll(dir, 0o700) != nil {
return false
}
if overwrite {
return os.WriteFile(path, body, 0o644) == nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
pendingCrashMu.Lock()
defer pendingCrashMu.Unlock()
name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
strconv.Itoa(os.Getpid()) + "-" +
strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
path := filepath.Join(dir, name)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false
}
defer f.Close()
n, err := f.Write(body)
if err != nil || n != len(body) {
closeErr := f.Close()
if err != nil || closeErr != nil || n != len(body) {
_ = os.Remove(path)
return false
}
paths := pendingCrashQueuePaths()
for len(paths) > maxPendingCrashes {
_ = os.Remove(paths[0])
paths = paths[1:]
}
return true
}
func pendingCrashQueuePaths() []string {
entries, err := os.ReadDir(pendingCrashDir())
if err != nil {
return nil
}
paths := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
}
}
sort.Strings(paths)
return paths
}
func pendingCrashPaths() []string {
paths := make([]string, 0, maxPendingCrashes+1)
if _, err := os.Stat(pendingCrashPath()); err == nil {
paths = append(paths, pendingCrashPath())
}
return append(paths, pendingCrashQueuePaths()...)
}
func removeAllPendingCrashes() {
_ = os.Remove(pendingCrashPath())
_ = os.RemoveAll(pendingCrashDir())
_ = os.Remove(fatalCrashCoveredPath())
}
func (a *App) goSafe(site string, fn func()) {
go func() {
defer a.recoverToPending(site)
@@ -88,32 +146,36 @@ func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
// Safe Mode boots from built-in defaults and cannot read the user's real
// telemetry preference, so it must neither send the pending report nor
// consume it: leave the file for the next normal boot to decide.
if config.SafeModeRequested() {
return
}
path := pendingCrashPath()
body, err := readFileUTF8(path)
if err != nil {
paths := pendingCrashPaths()
if len(paths) == 0 {
return
}
cfg, err := config.Load()
if err != nil || !cfg.DesktopTelemetry() {
_ = os.Remove(path)
if err != nil {
return
}
var r crashReport
if json.Unmarshal(body, &r) != nil {
_ = os.Remove(path)
if !cfg.DesktopTelemetry() {
removeAllPendingCrashes()
return
}
c, err := httpClient()
if err != nil {
return
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) == nil {
for _, path := range paths {
body, readErr := readFileUTF8(path)
if readErr != nil {
continue
}
var r crashReport
if json.Unmarshal(body, &r) != nil {
_ = os.Remove(path)
continue
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
break
}
_ = os.Remove(path)
}
_ = os.Remove(pendingCrashDir())
}
+33 -25
View File
@@ -13,7 +13,11 @@ import (
func readPending(t *testing.T) (crashReport, bool) {
t.Helper()
body, err := os.ReadFile(pendingCrashPath())
paths := pendingCrashPaths()
if len(paths) == 0 {
return crashReport{}, false
}
body, err := os.ReadFile(paths[0])
if err != nil {
return crashReport{}, false
}
@@ -25,7 +29,7 @@ func readPending(t *testing.T) (crashReport, bool) {
}
func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
t.Cleanup(removeAllPendingCrashes)
func() {
defer func() {
@@ -45,7 +49,7 @@ func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
if r.Kind != "crash" {
t.Errorf("kind = %q, want crash", r.Kind)
}
if !strings.Contains(r.Message, "[go panic] unit:") {
if !strings.Contains(r.Message, "[go panic] unit") {
t.Errorf("message missing site prefix: %q", r.Message)
}
if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
@@ -57,7 +61,7 @@ func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
}
func TestWritePendingCrashCaps(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
t.Cleanup(removeAllPendingCrashes)
writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
r, ok := readPending(t)
if !ok {
@@ -68,8 +72,8 @@ func TestWritePendingCrashCaps(t *testing.T) {
}
}
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
func TestWritePendingReportQueuesWithoutOverwritingExistingCrash(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
@@ -80,8 +84,8 @@ func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if writePendingReport(hang, false) {
t.Fatal("writePendingReport overwrite=false should not replace existing report")
if !writePendingReport(hang, false) {
t.Fatal("writePendingReport should enqueue the second report")
}
after, ok := readPending(t)
if !ok {
@@ -90,10 +94,13 @@ func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
if got := len(pendingCrashPaths()); got != 2 {
t.Fatalf("pending reports = %d, want 2", got)
}
}
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
func TestWritePendingReportQueueIsBoundedUnderConcurrentWriters(t *testing.T) {
t.Cleanup(removeAllPendingCrashes)
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
@@ -120,27 +127,28 @@ func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
close(start)
done.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
if got := successes.Load(); got != writers {
t.Fatalf("successful queued writers = %d, want %d", got, writers)
}
if _, ok := readPending(t); !ok {
t.Fatal("expected one pending report")
if got := len(pendingCrashPaths()); got != maxPendingCrashes {
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
t.Cleanup(removeAllPendingCrashes)
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
longHex := "0123456789abcdef0123456789abcdef"
writePendingCrash("unit", "boom api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
privatePanicValue := "private user prompt contents"
writePendingCrash("unit", privatePanicValue+" api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
for _, leaked := range []string{apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
for _, leaked := range []string{privatePanicValue, apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
if strings.Contains(freeText, leaked) {
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
}
@@ -151,7 +159,7 @@ func TestFlushPendingCrashSendsAndClears(t *testing.T) {
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
os.Remove(pendingCrashPath())
removeAllPendingCrashes()
})
version = "v9.9.9"
@@ -178,7 +186,7 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
oldVersion := version
t.Cleanup(func() {
version = oldVersion
os.Remove(pendingCrashPath())
removeAllPendingCrashes()
})
version = "dev"
@@ -190,19 +198,19 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
}
}
func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
// v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush. With telemetry
// off/default, the pending file is consumed (sent or dropped).
t.Setenv("REASONIX_SAFE_MODE", "1")
oldVersion := version
t.Cleanup(func() {
version = oldVersion
os.Remove(pendingCrashPath())
removeAllPendingCrashes()
})
version = "v9.9.9"
writePendingCrash("safe", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if _, ok := readPending(t); !ok {
t.Fatal("safe mode must leave the pending crash file for the next normal boot")
}
// Either sent or dropped is fine; must not retain solely because of Safe Mode env.
// When telemetry is off the file is removed; when on it is sent. Both clear it.
}
+26 -10
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
@@ -239,6 +238,13 @@ func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
a.clearDeferredRebuild(tabID)
return
}
a.mu.RLock()
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if path != "" && a.attachExistingSessionRuntime(tab, path, a.ctx) {
a.clearDeferredRebuild(tabID)
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
@@ -289,6 +295,7 @@ func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
tab.buildCancel = cancel
tab.Ready = false
clearTabStartupError(tab)
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
tab.ActivityStatus = ""
if tab.sink == nil {
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
@@ -366,16 +373,25 @@ func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
if path == "" {
return true // nothing to probe; let the rebuild decide
}
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
key := sessionRuntimeKey(path)
a.mu.RLock()
rt := a.runtimeBySessionKey[key]
ownedByTab := rt != nil && rt.Owner == tab
ownedByOther := rt != nil && rt.Owner != nil && rt.Owner != tab && a.runtimeOwnerLiveLocked(rt)
a.mu.RUnlock()
if ownedByOther {
return false
}
if ownedByTab && tab.sessionLeaseRuntimeKey() == key {
return true
}
lease, err := agent.TryAcquireSessionLease(key)
if err != nil {
var leaseErr *agent.SessionLeaseError
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
// This process (usually this very tab) holds the probed path;
// the blocking lease is some other path. Attempt the rebuild
// and let its own lease checks decide.
return true
}
if sameCurrentProcessLease(err) {
// The registry ruled out a live sibling owner above, so this is an
// orphaned current-process lease. Let the rebuild helper reclaim it
// under runtimeRebuildMu instead of looping against our own marker.
return true
}
return !errors.Is(err, agent.ErrSessionLeaseHeld)
}
+14 -3
View File
@@ -5,21 +5,29 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build",
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
"check:bundle": "node scripts/check-bundle-budget.mjs",
"preview": "vite preview",
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/workspace-layout.test.ts",
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && pnpm test:composer-menu-viewport",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/github-link-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/footer-decision-overflow.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-Line truncated
"test:remote": "tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && tsx src/__tests__/provider-trust-dialog.test.tsx && tsx src/__tests__/workbench-target.test.ts",
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote"
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"@tanstack/react-virtual": "^3.14.3",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"gsap": "^3.15.0",
"highlight.js": "^11.10.0",
"katex": "^0.17.0",
@@ -33,15 +41,18 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"svg-pan-zoom": "^3.6.2",
"unist-util-visit": "^5.1.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/jsdom": "^28.0.3",
"@types/mdast": "^4.0.4",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^29.1.1",
"mdast-util-math": "^3.0.0",
"terser": "^5.48.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
+37 -12
View File
@@ -17,6 +17,12 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.14.3
version: 3.14.3([email protected]([email protected]))([email protected])
'@xterm/addon-fit':
specifier: 0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: 6.0.0
version: 6.0.0
gsap:
specifier: ^3.15.0
version: 3.15.0
@@ -56,6 +62,9 @@ importers:
svg-pan-zoom:
specifier: ^3.6.2
version: 3.6.2
unist-util-visit:
specifier: ^5.1.0
version: 5.1.0
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/[email protected])([email protected])
@@ -63,6 +72,9 @@ importers:
'@types/jsdom':
specifier: ^28.0.3
version: 28.0.3
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
'@types/node':
specifier: ^26.0.0
version: 26.0.0
@@ -78,6 +90,9 @@ importers:
jsdom:
specifier: ^29.1.1
version: 29.1.1
mdast-util-math:
specifier: ^3.0.0
version: 3.0.0
terser:
specifier: ^5.48.0
version: 5.48.0
@@ -646,6 +661,12 @@ packages:
babel-plugin-react-compiler:
optional: true
'@xterm/[email protected]':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/[email protected]':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
[email protected]:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
engines: {node: '>=0.4.0'}
@@ -894,8 +915,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
[email protected]1:
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
[email protected]2:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
[email protected]:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
@@ -1287,8 +1308,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]2:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
[email protected]6:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1320,8 +1341,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
[email protected].15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
[email protected].23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
@@ -2066,6 +2087,10 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.0.16(@types/[email protected])([email protected])([email protected])([email protected])
'@xterm/[email protected]': {}
'@xterm/[email protected]': {}
[email protected]: {}
[email protected]: {}
@@ -2324,7 +2349,7 @@ snapshots:
dependencies:
dequal: 2.0.3
[email protected]1:
[email protected]2:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -2781,7 +2806,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.21
dompurify: 3.4.11
dompurify: 3.4.12
es-toolkit: 1.49.0
katex: 0.16.47
khroma: 2.1.0
@@ -2994,7 +3019,7 @@ snapshots:
[email protected]: {}
[email protected]2: {}
[email protected]6: {}
[email protected]: {}
@@ -3029,9 +3054,9 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
[email protected].15:
[email protected].23:
dependencies:
nanoid: 3.3.12
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -3308,7 +3333,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
postcss: 8.5.23
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -0,0 +1,57 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { basename, resolve } from "node:path";
import { gzipSync } from "node:zlib";
const distDir = resolve("dist");
const indexPath = resolve(distDir, "index.html");
const html = readFileSync(indexPath, "utf8");
function gzipBytes(path) {
return gzipSync(readFileSync(path), { level: 9 }).byteLength;
}
function initialAssetPaths(extension) {
const pattern = extension === ".js"
? /<(?:script|link)\b[^>]+(?:src|href)=["']([^"']+\.js)["'][^>]*>/g
: /<link\b[^>]+href=["']([^"']+\.css)["'][^>]*>/g;
return [...new Set([...html.matchAll(pattern)].map((match) => resolve(distDir, match[1])))];
}
function formatKiB(bytes) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
function assertBudget(label, actual, budget) {
if (actual > budget) {
throw new Error(`${label} is ${formatKiB(actual)}; budget is ${formatKiB(budget)}`);
}
process.stdout.write(` PASS ${label}: ${formatKiB(actual)} / ${formatKiB(budget)}\n`);
}
const initialJS = initialAssetPaths(".js");
const initialCSS = initialAssetPaths(".css");
if (!initialJS.length) throw new Error("no initial JavaScript assets found in dist/index.html");
if (!initialCSS.length) throw new Error("no initial CSS assets found in dist/index.html");
const initialJSGzip = initialJS.reduce((total, path) => total + gzipBytes(path), 0);
const initialCSSGzip = initialCSS.reduce((total, path) => total + gzipBytes(path), 0);
const largestInitialJS = Math.max(...initialJS.map(gzipBytes));
const largestInitialJSRaw = Math.max(...initialJS.map((path) => statSync(path).size));
const localeChunks = readdirSync(resolve(distDir, "assets"))
.filter((name) => /^(?:zh|zh-TW)-.+\.js$/.test(name))
.map((name) => resolve(distDir, "assets", name));
console.log("\nbundle budgets");
assertBudget("initial JavaScript gzip", initialJSGzip, 430 * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 295 * 1024);
assertBudget("initial CSS gzip", initialCSSGzip, 112 * 1024);
if (localeChunks.length !== 2) {
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
}
for (const path of localeChunks) {
assertBudget(`${basename(path)} gzip`, gzipBytes(path), 52 * 1024);
}
const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0);
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_250 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024);
File diff suppressed because it is too large. Load diff
@@ -298,8 +298,16 @@ await waitFor("queued last click applies once it runs", () => controller?.active
// enqueue time — the queue-based scenario above only proves the mechanism.
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
ok(
/const enqueueNavigation = useCallback\(\(input: DesktopNavigationInput\)[\s\S]{0,700}?noteNavigationIntent\(\);[\s\S]{0,700}?enqueueNavigationRequest\(/.test(appSource),
"App.enqueueNavigation calls noteNavigationIntent() before enqueueNavigationRequest",
/const enqueueNavigation = useCallback\(\(input: DesktopNavigationIntent\)[\s\S]{0,900}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,900}?enqueueNavigationRequest\(/.test(appSource),
"App.enqueueNavigation captures a shared navigation intent before enqueueNavigationRequest",
);
ok(
/const enqueueTabSwitch = useCallback\([\s\S]{0,1400}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,1400}?switchTab\(request\.tabId, request\.optimisticTab, request\.navigationIntentSeq\)/.test(appSource),
"App.enqueueTabSwitch invalidates older navigation at enqueue time and forwards the shared intent",
);
ok(
/const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\)/.test(appSource),
"App navigation results require both queue ownership and the shared navigation intent",
);
console.log(`\n${passed} passed, ${failed} failed`);
@@ -49,11 +49,11 @@ async function nextFrame() {
});
}
function Harness() {
function Harness({ showAnchor = true }: { showAnchor?: boolean }) {
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={anchorRef} data-testid="anchor">Anchor</button>
{showAnchor && <button ref={anchorRef} data-testid="anchor">Anchor</button>}
<AnchoredPopover
open
anchorRef={anchorRef}
@@ -103,14 +103,27 @@ if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(<Harness />);
root.render(<Harness showAnchor={false} />);
});
await nextFrame();
const popover = document.querySelector<HTMLElement>("[data-anchored-popover='active']");
if (!popover) throw new Error("popover did not render");
eq(popover.style.left, "8px", "unmeasured popover uses the visible horizontal fallback");
eq(popover.style.top, "8px", "unmeasured popover uses the visible vertical fallback");
eq(popover.style.visibility, "visible", "unmeasured popover remains visible");
eq(popover.dataset.ready, "false", "fallback position does not pretend layout is ready");
// Exhaust the first scheduled measurement while the anchor is unavailable.
await nextFrame();
await act(async () => {
root.render(<Harness />);
});
await nextFrame();
eq(popover.style.top, "138px", "popover starts below the anchor");
eq(popover.dataset.ready, "true", "popover becomes ready after measurement recovers");
await act(async () => {
anchorTop = 40;
@@ -120,6 +133,15 @@ await act(async () => {
eq(popover.style.top, "78px", "popover follows the anchor after a scroll event");
await act(async () => {
root.render(<Harness showAnchor={false} />);
window.dispatchEvent(new Event("scroll", { bubbles: true }));
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
});
eq(popover.style.top, "78px", "popover keeps its last valid position while the anchor is unavailable");
eq(popover.dataset.ready, "true", "temporary anchor loss keeps the last measured position ready");
await act(async () => {
root.unmount();
});
@@ -3,6 +3,8 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, tabMetaFallbackDelay } from "../lib/tabMetaRefresh";
import type { TabMeta } from "../lib/types";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
@@ -30,6 +32,16 @@ function ok(value: unknown, label: string) {
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function matchingBlocks(selector: string): string[] {
const blocks: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
@@ -55,6 +67,179 @@ function finalDeclaration(selector: string, property: string): string | undefine
console.log("\napp chrome tabs");
const tabMeta = (overrides: Partial<TabMeta> = {}): TabMeta => ({
id: "tab-1",
scope: "project",
workspaceRoot: "/repo",
workspaceName: "repo",
topicId: "topic-1",
topicTitle: "Topic",
label: "model",
ready: true,
running: false,
cancellable: false,
mode: "normal",
active: true,
cwd: "/repo",
...overrides,
});
ok(sameTabMetaLists([tabMeta()], [tabMeta()]), "identical tab metadata suppresses redundant state writes");
ok(!sameTabMetaLists([tabMeta()], [tabMeta({ running: true })]), "runtime tab changes still invalidate metadata state");
ok(tabMetaFallbackDelay("visible") === 15_000, "visible tab metadata fallback runs at low frequency");
ok(tabMetaFallbackDelay("hidden") === 60_000, "hidden tab metadata fallback backs off further");
ok(shouldRefreshTabMetaForEvent("turn_started"), "turn start refreshes tab runtime metadata immediately");
ok(shouldRefreshTabMetaForEvent("approval_request"), "approval prompts refresh tab runtime metadata immediately");
ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger tab-list requests");
{
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
const first = deferred<TabMeta[]>();
const second = deferred<TabMeta[]>();
let loads = 0;
const firstRefresh = coordinator.run(() => {
loads += 1;
return first.promise;
});
const secondRefresh = coordinator.run(() => {
loads += 1;
return second.promise;
});
const saturatedRefresh = coordinator.run(() => {
loads += 1;
return Promise.resolve([]);
});
await Promise.resolve();
ok(loads === 2, "tab metadata refresh caps outstanding backend calls");
const latestTabs = [tabMeta({ id: "tab-latest" })];
second.resolve(latestTabs);
const saturatedResult = await saturatedRefresh;
ok(saturatedResult.coalesced, "saturated tab metadata refresh joins the newest request");
ok(saturatedResult.value === latestTabs, "saturated tab metadata refresh returns authoritative tabs instead of an empty sentinel");
ok(saturatedResult.latest, "coalesced newest tab metadata remains eligible to update state");
first.resolve([tabMeta({ id: "tab-stale" })]);
const firstResult = await firstRefresh;
await secondRefresh;
ok(!firstResult.latest, "an older tab metadata response cannot replace a newer snapshot");
}
{
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
const preMutationA = deferred<TabMeta[]>();
const preMutationB = deferred<TabMeta[]>();
const postMutation = deferred<TabMeta[]>();
let loads = 0;
const loadValues: TabMeta[][] = [
[tabMeta({ id: "pre-a" })],
[tabMeta({ id: "pre-b" })],
[tabMeta({ id: "post-mutation" })],
];
const loadPromises = [preMutationA.promise, preMutationB.promise, postMutation.promise];
const firstRefresh = coordinator.run(() => {
const index = loads;
loads += 1;
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
});
const secondRefresh = coordinator.run(() => {
const index = loads;
loads += 1;
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
});
await Promise.resolve();
ok(loads === 2, "pre-mutation tab metadata fills both in-flight slots");
const mutationRefresh = coordinator.run(
() => {
const index = loads;
loads += 1;
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
},
{ invalidate: true },
);
await Promise.resolve();
ok(loads === 2, "post-mutation refresh does not start until an in-flight slot frees");
preMutationA.resolve(loadValues[0]);
const firstResult = await firstRefresh;
await Promise.resolve();
await Promise.resolve();
ok(loads === 3, "post-mutation refresh starts as a trailing load after a slot frees");
ok(!firstResult.latest, "pre-mutation snapshot is not authoritative after invalidate");
const postTabs = loadValues[2];
postMutation.resolve(postTabs);
const mutationResult = await mutationRefresh;
ok(!mutationResult.coalesced, "post-mutation refresh does not join a pre-mutation request");
ok(mutationResult.value === postTabs, "post-mutation refresh returns the mutation-after snapshot");
ok(mutationResult.latest, "post-mutation trailing refresh remains eligible to update state");
preMutationB.resolve(loadValues[1]);
const secondResult = await secondRefresh;
ok(!secondResult.latest, "pre-mutation coalesced request cannot overwrite post-mutation state");
}
{
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(1);
const preMutation = deferred<TabMeta[]>();
const latestPostMutation = deferred<TabMeta[]>();
const started: string[] = [];
const preMutationRefresh = coordinator.run(() => {
started.push("pre");
return preMutation.promise;
});
await Promise.resolve();
const firstMutationRefresh = coordinator.run(
() => {
started.push("first-mutation");
return Promise.resolve([tabMeta({ id: "first-mutation" })]);
},
{ invalidate: true },
);
const latestMutationRefresh = coordinator.run(
() => {
started.push("latest-mutation");
return latestPostMutation.promise;
},
{ invalidate: true },
);
preMutation.resolve([tabMeta({ id: "pre" })]);
const preMutationResult = await preMutationRefresh;
await Promise.resolve();
await Promise.resolve();
ok(started.join(",") === "pre,latest-mutation", "queued invalidations retain only the latest trailing load");
ok(!preMutationResult.latest, "queued invalidations fence the pre-mutation load");
const latestTabs = [tabMeta({ id: "latest-mutation" })];
latestPostMutation.resolve(latestTabs);
const [firstMutationResult, latestMutationResult] = await Promise.all([firstMutationRefresh, latestMutationRefresh]);
ok(firstMutationResult.value === latestTabs, "an older queued mutation waits for the latest post-mutation snapshot");
ok(latestMutationResult.value === latestTabs, "the latest queued mutation receives its post-mutation snapshot");
ok(firstMutationResult.latest && latestMutationResult.latest, "the shared trailing snapshot remains authoritative");
}
ok(
!appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") &&
appSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') &&
appSource.includes("createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT)") &&
appSource.includes("void refreshTabMetas();\n schedule();"),
"tab metadata refresh is event-driven with a visibility-aware fallback",
);
ok(
appSource.includes("refreshTabMetas(undefined, { afterMutation: true })") &&
appSource.includes("{ afterMutation: true }") &&
appSource.includes("if (shouldRefreshTabMetaForEvent(e.kind)) {") &&
appSource.includes("void refreshTabMetas(undefined, { afterMutation: true });") &&
/await refreshTabMetas\(\s*\(\) => isNavigationIntentCurrent\(request\.navigationIntentSeq\),\s*\{\s*afterMutation:\s*true\s*\},?\s*\)/.test(appSource),
"tab lifecycle events and explicit mutations force a post-mutation trailing metadata refresh",
);
ok(
/import \{ TabBar \} from "\.\/TabBar";/.test(appChromeSource),
"AppChrome keeps the classic top session tab strip implementation",
@@ -245,7 +430,7 @@ ok(
);
ok(
/const controllerReady = state\.meta\?\.ready === true && !state\.backendActivationPending && !runtimeTransitioning;/.test(appSource) &&
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
/submitDisabled=\{!controllerReady\}/.test(appSource),
@@ -261,7 +446,7 @@ ok(
ok(
/commitThenSendRef\.current\(sourceTabId, trimmed, submitText\.trim\(\), structured\)/.test(appSource) &&
/sendToTab\(sourceTabId, displayText, submitText, undefined, structured\)/.test(appSource) &&
/sendToTab\(sourceTabId, displayText, submitText, undefined, structured, initialGoal\)/.test(appSource) &&
/onSteer=\{handleSteer\}/.test(appSource) &&
/composerInsertRequestsByTab\[activeTabId\]/.test(appSource) &&
/consumedInsertIdByDraftRef\.current\[draftKey\]/.test(composerSource),
@@ -318,11 +503,11 @@ ok(
/const navigationRunningRef = useRef\(false\);/.test(appSource) &&
/const navigationPendingRef = useRef<PendingDesktopNavigationRequest \| null>\(null\);/.test(appSource) &&
/const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
/const latest = \(\) => request\.seq === navigationSeqRef\.current;/.test(appSource) &&
/return activateTopic\(scope, workspaceRoot, topicId/.test(appSource) &&
/return openTopicSession\(scope, workspaceRoot, topicId/.test(appSource) &&
/return openGlobalTab\(topicId\)/.test(appSource) &&
/return openProjectTab\(workspaceRoot, topicId\)/.test(appSource) &&
/const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\);/.test(appSource) &&
/return activateTopic\(scope, workspaceRoot, topicId, sessionPath \|\| "", request\.navigationIntentSeq\)/.test(appSource) &&
/return openTopicSession\(scope, workspaceRoot, topicId, sessionPath, request\.navigationIntentSeq\)/.test(appSource) &&
/return openGlobalTab\(topicId, request\.navigationIntentSeq\)/.test(appSource) &&
/return openProjectTab\(workspaceRoot, topicId, request\.navigationIntentSeq\)/.test(appSource) &&
/enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
!/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
/const refreshLatestTabMetas = async \(\): Promise<TabMeta\[]> => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
@@ -423,7 +608,7 @@ for (const selector of [
]) {
ok(
finalDeclaration(selector, "--app-chrome-height") === "0px" &&
finalDeclaration(selector, "grid-template-rows") === "minmax(0, 1fr)" &&
finalDeclaration(selector, "grid-template-rows") === "minmax(0, 1fr) var(--statusbar-height)" &&
finalDeclaration(selector, "background") === "var(--bg)",
`${selector} removes the workbench chrome row`,
);
@@ -454,6 +639,22 @@ ok(
"maximized workbench dock keeps a draggable title region while tabs remain clickable",
);
ok(
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
"active dock underline stays inside the visible dock edge",
);
const narrowDockRule = stylesSource.match(/@container \(max-width: 420px\) \{[\s\S]*?\.app--windows-frameless\.app--workbench \.workbench-dock__tab[\s\S]*?\n\}/)?.[0] || "";
ok(
narrowDockRule.includes("padding-left: clamp(") &&
narrowDockRule.includes("gap: clamp(") &&
narrowDockRule.includes("flex: 0 0 auto") &&
narrowDockRule.includes("max-width: none") &&
!narrowDockRule.includes("text-overflow: ellipsis") &&
!narrowDockRule.includes("overflow: hidden"),
"narrow Windows frameless workbench compresses spacing without shrinking or truncating dock tabs",
);
for (const selector of [
":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar",
":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn",
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { activeFileReferenceToken, pickInlineFileReference } from "../components/FileReferenceMenu";
import { LocaleProvider } from "../lib/i18n";
import { LocaleProvider, preloadDetectedLocale } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WireApproval } from "../lib/types";
@@ -95,6 +95,7 @@ function mockApp(methods: Partial<AppBindings>) {
}
async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]> = {}) {
await preloadDetectedLocale();
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
@@ -328,6 +329,70 @@ console.log("\napproval modal file references");
dom.window.close();
}
{
const dom = installDom("zh-CN");
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root } = await renderApproval({
approval: {
id: "dynamic-bash-zh",
tool: "bash",
subject: "python3 -c \"print('hello')\"",
reason: "Matched permission rule: ask Bash(python3:*)\nThis command uses nested or indirect shell execution. Auto and broad allow rules cannot verify the inner command; approve this exact command or use YOLO.",
},
});
const text = document.body.textContent ?? "";
ok(text.includes("命中权限规则:ask Bash(python3:*)"), "approval identifies the exact matched permission rule");
ok(text.includes("嵌套或间接执行"), "dynamic Bash approval explains the matched safety boundary in Chinese");
ok(text.includes("精确命令"), "dynamic Bash approval tells the user how to grant the command");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
ListDir: async () => [],
SearchFileRefs: async () => [],
});
const { root, rerender } = await renderApproval();
await clickImmediateAction("Revise plan");
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("plan revision textarea did not render");
ok(textarea === document.activeElement, "opening plan revision focuses its textarea once");
const transcriptText = document.createElement("p");
transcriptText.tabIndex = -1;
transcriptText.textContent = "copy this plan text";
document.body.appendChild(transcriptText);
transcriptText.focus();
const range = document.createRange();
range.selectNodeContents(transcriptText);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
// App refreshes tab metadata periodically; emulate callback churn from a parent rerender.
await rerender({ onRevisionActiveChange: () => undefined });
ok(document.activeElement === transcriptText, "parent rerender does not return focus to plan revision");
eq(document.getSelection()?.toString(), "copy this plan text", "parent rerender preserves transcript text selection");
transcriptText.remove();
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -65,7 +65,7 @@ ok(
const errorStart = snapshotBreadcrumbs().length;
now = 100;
try {
await app.CheckUpdate();
await app.CheckUpdate("");
} catch {
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
}
@@ -22,6 +22,8 @@ const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
const mainSource = readFileSync(resolve(here, "../main.tsx"), "utf8");
const stylesSource = readFileSync(resolve(here, "../styles.css"), "utf8");
console.log("\nbundle contract");
@@ -97,6 +99,20 @@ ok(
markdownSource.includes('import("./MarkdownRenderer")'),
"Markdown wrapper loads markdown renderer on demand",
);
ok(
!/import\s+\{\s*zh\s*\}\s+from\s+["']\.\.\/locales\/zh["']/.test(i18nSource) &&
!/import\s+\{\s*zhTW\s*\}\s+from\s+["']\.\.\/locales\/zh-TW["']/.test(i18nSource),
"i18n keeps Chinese dictionaries out of the unconditional initial chunk",
);
ok(
i18nSource.includes('import("../locales/zh")') &&
i18nSource.includes('import("../locales/zh-TW")'),
"i18n loads Chinese dictionaries on demand",
);
ok(
mainSource.includes("await preloadDetectedLocale()"),
"main preloads the detected locale before mounting React",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -435,6 +435,7 @@ console.log("capabilities panel MCP actions");
await flush();
});
await waitFor("github server row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("github")));
ok(Boolean(findButton("Remove server")), "configured MCP exposes a removal action directly in the server list");
ok(document.body.textContent?.includes("1 unavailable"), "server list summary reports one quarantined tool");
ok(!document.body.textContent?.includes("invalid input schema: bad nested type"), "server list keeps raw tool diagnostics out of the overview");
@@ -482,24 +483,35 @@ console.log("capabilities panel MCP actions");
active: true,
cwd: "/tmp/reasonix-test",
}];
let projectAuthorizationCount = 0;
let servers: ServerView[] = [{
name: "github",
transport: "stdio",
status: "failed",
runtimeState: "issue",
status: "connected",
runtimeState: "ready",
configured: true,
source: "project",
configSource: "reasonix.toml",
autoStart: true,
tools: 0,
tools: 3,
prompts: 0,
resources: 0,
error: "project-provided MCP server is blocked until the user authorizes it",
requiresLaunchApproval: true,
toolList: [
{ name: "issue_read", description: "Read issues.", readOnlyHint: true },
{ name: "issue_write", description: "Write issues." },
{ name: "wipe", description: "Delete data.", destructiveHint: true },
],
}, {
name: "linear",
transport: "http",
status: "connected",
runtimeState: "ready",
configured: true,
source: "user",
autoStart: true,
tools: 1,
prompts: 0,
resources: 0,
toolList: [{ name: "get_issue", description: "Read an issue.", readOnlyHint: true }],
}];
window.go = {
main: {
@@ -507,16 +519,6 @@ console.log("capabilities panel MCP actions");
Meta: async () => meta,
ListTabs: async () => tabs,
MCPServers: async () => servers,
AuthorizeAndConnectMCPServer: async () => {
projectAuthorizationCount += 1;
servers = servers.map((item) => ({
...item,
status: "connected",
runtimeState: "ready",
requiresLaunchApproval: false,
error: "",
}));
},
} as Partial<AppBindings> as AppBindings,
},
};
@@ -533,16 +535,17 @@ console.log("capabilities panel MCP actions");
await flush();
});
};
await waitFor("project MCP authorization action", () => Boolean(findButton("Authorize and connect")));
ok(!findButton("Review changes"), "project MCP launch approval has no separate change-review workflow");
await waitFor("trusted project MCP", () => Boolean(document.querySelector('[data-status="connected"]')));
ok(document.body.textContent?.includes("This project"), "project MCP is grouped under This project");
ok(document.body.textContent?.includes("Global MCP"), "user-installed MCP is grouped by its global scope");
ok(document.body.textContent?.includes("Install once and use automatically in every Reasonix project."), "global MCP explains its cross-project availability");
ok(document.body.textContent?.includes("Project"), "project MCP row shows a project source badge");
ok(document.body.textContent?.includes("Declared by this project and available automatically."), "project MCP explains zero-confirmation availability");
ok(!findButton("Install and use"), "trusted project MCP has no install confirmation");
ok(!findButton("Authorize and connect"), "trusted project MCP has no authorization action");
ok(!findButton("Review changes"), "project MCP has no separate change-review workflow");
ok(!findButton("Refresh catalog"), "catalog maintenance is not part of the normal MCP workflow");
ok(!document.querySelector('[role="dialog"]'), "project authorization does not open a second modal");
await act(async () => {
findButton("Authorize and connect")?.click();
await flush();
});
await waitFor("durable project launch authorization", () => projectAuthorizationCount === 1 && Boolean(document.querySelector('[data-status="connected"]')));
ok(!document.querySelector('[role="dialog"]'), "project confirmation connects directly without a second modal");
ok(!document.querySelector('[role="dialog"]'), "project MCP does not open a confirmation modal");
servers = servers.map((item) => ({
...item,
@@ -576,13 +579,14 @@ console.log("capabilities panel MCP actions");
requiresLaunchApproval: false,
}));
await refreshStatus();
await waitFor("authorized project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
await waitFor("trusted project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
await act(async () => {
(document.querySelector(".cap-mcp-list-row__main") as HTMLButtonElement | null)?.click();
await flush();
});
await waitFor("connected project server detail", () => Boolean(document.querySelector(".cap-mcp-subpage")));
ok(!findButton("Review changes"), "an authorized connected project server does not show a change alarm");
ok(document.body.textContent?.includes("Current project · reasonix.toml"), "project MCP details show their configuration source");
ok(!findButton("Review changes"), "a trusted connected project server does not show a change alarm");
ok(!findButton("Revoke trust"), "normal MCP details do not expose a second authorization-management workflow");
await act(async () => {
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/composer-context-menu-clipboard.test.tsx
//
// Regression coverage for the composer edit context menu's clipboard guards:
// - Undo/redo must expose the same fixed shortcuts as the composer keyboard path.
// - Cut must not delete the selection when every clipboard path failed.
// - Paste must not replace the selection when the clipboard has no text.
// - Shortcut hints must use the platform modifier (Ctrl outside macOS).
@@ -193,7 +194,7 @@ async function openInputMenu(): Promise<HTMLButtonElement[]> {
await flushTimers();
});
const items = Array.from(document.querySelectorAll(".context-menu__item")) as HTMLButtonElement[];
if (items.length !== 4) throw new Error(`expected 4 edit menu items, got ${items.length}`);
if (items.length !== 6) throw new Error(`expected 6 edit menu items, got ${items.length}`);
return items;
}
@@ -235,8 +236,10 @@ async function main() {
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
const hints = items.map((item) => item.querySelector(".context-menu__shortcut")?.textContent ?? "");
eq(hints[0], "Ctrl+X", "cut hint uses the platform modifier");
eq(hints[2], "Ctrl+V", "paste hint uses the platform modifier");
eq(hints[0], "Ctrl+Z", "undo hint uses the platform modifier");
eq(hints[1], "Ctrl+Shift+Z", "redo hint uses the platform modifier");
eq(hints[2], "Ctrl+X", "cut hint uses the platform modifier");
eq(hints[4], "Ctrl+V", "paste hint uses the platform modifier");
ok(hints.every((hint) => !hint.includes("⌘")), "no hardcoded mac glyph on a non-mac platform");
// Close the menu without acting.
await act(async () => {
@@ -245,13 +248,29 @@ async function main() {
});
}
// --- context-menu undo and redo share the programmatic edit history ---
{
await typeAndSelect("menu undo", 9, 9);
const undoItems = await openInputMenu();
ok(!undoItems[0].disabled, "undo is enabled at a programmatic edit boundary");
ok(undoItems[1].disabled, "redo is disabled before an undo");
await clickMenuItem(undoItems[0]);
eq(textarea().value, "hello world", "context-menu undo restores the previous composer text");
const redoItems = await openInputMenu();
ok(redoItems[0].disabled === false, "older programmatic history remains undoable");
ok(!redoItems[1].disabled, "redo is enabled after context-menu undo");
await clickMenuItem(redoItems[1]);
eq(textarea().value, "menu undo", "context-menu redo restores the undone composer edit");
}
// --- cut keeps the draft when every clipboard path fails ---
{
stubClipboard({});
(document as Document & { execCommand?: () => boolean }).execCommand = () => false;
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
await clickMenuItem(items[0]);
await clickMenuItem(items[2]);
eq(textarea().value, "hello world", "failed cut must not delete the selection");
}
@@ -260,7 +279,7 @@ async function main() {
stubClipboard({ writeText: () => Promise.resolve() });
await typeAndSelect("hello world", 0, 6);
const items = await openInputMenu();
await clickMenuItem(items[0]);
await clickMenuItem(items[2]);
eq(textarea().value, "world", "successful cut removes the selected text");
}
@@ -269,7 +288,7 @@ async function main() {
stubClipboard({ readText: () => Promise.resolve("") });
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
await clickMenuItem(items[2]);
await clickMenuItem(items[4]);
eq(textarea().value, "hello world", "empty-clipboard paste must not erase the selection");
}
@@ -278,7 +297,7 @@ async function main() {
stubClipboard({ readText: () => Promise.resolve("bye") });
await typeAndSelect("hello world", 0, 5);
const items = await openInputMenu();
await clickMenuItem(items[2]);
await clickMenuItem(items[4]);
eq(textarea().value, "bye world", "text paste replaces the selection");
}
@@ -6,10 +6,11 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer, composerPickFileEntry } from "../components/Composer";
import { InvocationMetadataContext, UserMessage } from "../components/Message";
import { selectionFromDom } from "../components/RichComposerInput";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { AppBindings } from "../lib/bridge";
import type { StructuredInvocationSubmit } from "../lib/invocationDisplay";
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
let passed = 0;
@@ -334,6 +335,142 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "ui-ux-pro-max", description: "Review the interface", kind: "skill" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const { root, calls, rerender } = await renderComposer({ collaborationMode: "goal", goal: "" });
await replaceComposerDraft(rerender, 4199, "/ui-ux-pro-max");
await waitFor("skill menu for the initial goal", () => Boolean(document.querySelector(".slashmenu")));
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render for the initial goal skill");
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushTimers();
});
let sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render for the initial goal skill");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.length, 0, "a skill alone cannot become the initial goal");
ok(document.body.textContent?.includes("Enter a goal") === true, "a skill-only initial goal asks for task text");
await replaceComposerDraft(rerender, 4200, "List the existing notes");
sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button disappeared after entering the goal");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send[0], "List the existing notes", "the initial goal keeps its visible task text");
eq(calls.submit[0], "/ui-ux-pro-max List the existing notes", "the initial goal preserves the selected skill");
eq(calls.structured[0]?.input, "List the existing notes", "the initial goal sends structured skill input");
eq(calls.structured[0]?.invocations[0]?.name, "ui-ux-pro-max", "the initial goal submits the selected skill entity");
await replaceComposerDraft(rerender, 4201, "/ui-ux-pro-max List the notes again");
sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("composer send button did not render for a pasted skill invocation");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send[1], "List the notes again", "a pasted skill invocation keeps its task as the visible goal");
eq(calls.submit[1], "/ui-ux-pro-max List the notes again", "a pasted skill invocation keeps its slash display");
eq(calls.structured[1]?.input, "List the notes again", "a pasted skill invocation uses structured input");
eq(calls.structured[1]?.invocations[0]?.name, "ui-ux-pro-max", "a pasted skill invocation resolves the selected command");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// Attachment-only first Goal: no text, no skill — attachment refs are valid task context.
const dom = installDom();
mockApp({
SavePastedFile: async () => ".reasonix/attachments/notes.txt",
});
const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render for attachment-only goal");
await act(async () => {
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
await flushTimers();
});
await waitFor("attachment-only initial goal card", () => document.body.textContent?.includes("notes.txt") === true);
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("send button missing for attachment-only initial goal");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.length, 1, "attachment-only input can become the initial Goal");
ok(
calls.submit[0]?.includes("@.reasonix/attachments/notes.txt") === true,
"attachment-only initial Goal submits the attachment ref",
);
eq(calls.structured[0], undefined, "attachment-only initial Goal is not a structured skill submit");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// Workspace-ref-only first Goal: no text, no skill — workspace refs remain valid task context.
const dom = installDom();
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
window.runtime = {
EventsOn: () => () => {},
BrowserOpenURL: () => {},
OnFileDrop: (cb) => {
droppedCallback = cb;
},
OnFileDropOff: () => {},
};
mockApp({
AttachDropped: async () => ({
kind: "workspace",
path: "src/App.tsx",
isDir: false,
displayPath: "src/App.tsx",
}),
});
const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
if (!droppedCallback) throw new Error("native file drop handler did not register for workspace-ref goal");
await act(async () => {
droppedCallback?.(0, 0, ["/repo/src/App.tsx"]);
await flushTimers();
});
await waitFor("workspace-ref-only initial goal card", () => document.body.textContent?.includes("App.tsx") === true);
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("send button missing for workspace-ref-only initial goal");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.send.length, 1, "workspace-ref-only input can become the initial Goal");
eq(calls.submit[0], "@src/App.tsx", "workspace-ref-only initial Goal submits the workspace ref");
eq(calls.structured[0], undefined, "workspace-ref-only initial Goal is not a structured skill submit");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
@@ -437,6 +574,126 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
const command: CommandInfo = {
name: "writing-plans",
description: "Write a plan",
kind: "skill",
};
mockApp({
Commands: async () => [command],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const { root, rerender } = await renderComposer();
await replaceComposerDraft(rerender, 4201, "/writing-plans");
await waitFor("skill menu for paste undo selection", () => Boolean(document.querySelector(".slashmenu")));
const initialTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!initialTextarea) throw new Error("composer textarea did not render for paste undo selection");
await act(async () => {
initialTextarea.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
let token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
const invocationId = token?.dataset.invocationId;
if (!richInput || !token || !invocationId) throw new Error("rich invocation did not render for paste undo selection");
const afterToken = document.createRange();
afterToken.setStartAfter(token);
afterToken.collapse(true);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(afterToken);
await act(async () => {
dispatchPasteText(richInput!, "pasted");
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after paste");
eq(richComposerTaskText(richInput), "pasted", "paste after an invocation inserts on the token's right side");
await act(async () => {
richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await flushTimers();
});
let richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
eq(richMenuItems.length, 6, "rich composer exposes the shared edit context menu");
ok(richMenuItems[0]?.disabled === false, "rich composer context-menu undo is enabled after paste");
ok(richMenuItems[1]?.disabled === true, "rich composer context-menu redo is disabled before undo");
await act(async () => {
richMenuItems[0]?.click();
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after context-menu undo");
eq(richComposerTaskText(richInput), "", "rich composer context-menu undo removes the pasted text");
await act(async () => {
richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await flushTimers();
});
richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
ok(richMenuItems[1]?.disabled === false, "rich composer context-menu redo is enabled after undo");
await act(async () => {
richMenuItems[1]?.click();
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after context-menu redo");
eq(richComposerTaskText(richInput), "pasted", "rich composer context-menu redo restores the pasted text");
const undoPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
richInput!.dispatchEvent(undoPaste);
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await flushTimers();
});
eq(undoPaste.defaultPrevented, true, "Ctrl+Z restores the rich-composer paste transaction");
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
if (!richInput || !token) throw new Error("rich invocation disappeared after paste undo");
const restoredInvocation: ComposerInvocation = { id: invocationId, offset: 0, command };
const restoredSelection = selectionFromDom(
richInput,
new Map([[invocationId, restoredInvocation]]),
);
eq(
restoredSelection.ok ? restoredSelection.selection.afterInvocationId : undefined,
invocationId,
"paste undo restores the caret after the invocation token",
);
await act(async () => {
richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Backspace",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
ok(
document.querySelector(".composer-invocation-token") === null,
"Backspace after paste undo removes the invocation on the caret's left",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const { root, calls } = await renderComposer({
@@ -973,6 +1230,112 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
// A backend steer rejection means the turn crossed its final admission
// boundary. Keep the guidance item, then submit it as a normal follow-up
// after TurnDone instead of treating the rejected call as consumed.
const dom = installDom();
let steerAttempts = 0;
const { root, calls, rerender } = await renderComposer({
running: true,
onSteer: async () => {
steerAttempts += 1;
throw new Error("turn ended before guidance could be applied");
},
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
return Promise.resolve();
},
});
await rerender({ insertRequest: { id: 71, text: "preserve this late guidance", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
const guideButton = guidanceItem?.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("late guidance guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(steerAttempts, 1, "late guidance attempts one strict steer admission");
eq(calls.send.length, 0, "rejected steer does not open a provider turn");
ok(document.querySelector(".composer-guidance-item") !== null, "rejected steer remains queued");
await rerender({ running: false });
await waitFor("rejected steer sent as follow-up", () => calls.send.length === 1);
eq(calls.send[0], "preserve this late guidance", "late guidance becomes the next explicit user turn");
ok(document.querySelector(".composer-guidance-item") === null, "follow-up clears only after successful send");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// TurnDone can reach the frontend before an in-flight TrySteer rejection.
// Once that rejection settles, the preserved guidance must be re-evaluated
// as the next explicit turn instead of remaining stranded on the shelf.
const dom = installDom();
let steerAttempts = 0;
let rejectSteer: (error: Error) => void = () => {};
const pendingSteer = new Promise<void>((_, reject) => {
rejectSteer = reject;
});
pendingSteer.catch(() => {});
const { root, calls, rerender } = await renderComposer({
running: true,
onSteer: () => {
steerAttempts += 1;
return pendingSteer;
},
onSend: (displayText, submitText) => {
calls.send.push(displayText);
calls.submit.push(submitText);
return Promise.resolve();
},
});
await rerender({ insertRequest: { id: 72, text: "retry after TurnDone wins", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
if (!guideButton) throw new Error("deferred steer guide button did not render");
await act(async () => {
guideButton.click();
await flushTimers();
});
eq(steerAttempts, 1, "deferred guidance starts one strict steer admission");
await rerender({ running: false });
eq(calls.send.length, 0, "TurnDone waits for the in-flight steer result before follow-up");
await act(async () => {
rejectSteer(new Error("turn ended before guidance could be applied"));
await flushTimers();
});
await waitFor("deferred rejected steer sent as follow-up", () => calls.send.length === 1);
eq(calls.send[0], "retry after TurnDone wins", "deferred rejection becomes the next explicit user turn");
eq(steerAttempts, 1, "deferred rejection is not retried as another steer");
ok(document.querySelector(".composer-guidance-item") === null, "deferred follow-up clears after successful send");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// Reproduces #6210: a message queued while a turn is running, without the
// explicit "guide" steer click, must not vanish when the turn ends on its
@@ -1012,6 +1375,42 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
// A normal follow-up failure must remain user-controlled. The steer-race
// re-arm above must not turn ordinary onSend failures into a retry loop.
const dom = installDom();
let followupAttempts = 0;
const { root, rerender } = await renderComposer({
running: true,
onSend: () => {
followupAttempts += 1;
return Promise.reject(new Error("controller is not ready"));
},
});
await rerender({ insertRequest: { id: 81, text: "keep failed follow-up", mode: "replace" } });
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("running composer send button did not render");
await act(async () => {
sendButton.click();
await flushTimers();
});
await rerender({ running: false });
await waitFor("queued follow-up attempts once", () => followupAttempts === 1);
await act(async () => {
await flushTimers();
await flushTimers();
});
eq(followupAttempts, 1, "failed normal follow-up is not retried automatically");
ok(document.querySelector(".composer-guidance-item") !== null, "failed normal follow-up remains on the shelf");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
// #6210 follow-up: if the turn ends naturally while the controller is
// still activating/hydrating (submitDisabled), onSend would silently
@@ -1252,6 +1651,119 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "writing-plans", description: "Write a plan", kind: "skill", color: "amber" },
{ name: "review", description: "Review the result", kind: "skill" },
{ name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const { root, calls, rerender } = await renderComposer();
const initialText = "请用/writing-plans检查";
await replaceComposerDraft(rerender, 1900, initialText);
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render for middle slash completion");
const slashCaret = "请用/writ".length;
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
await flushTimers();
});
await act(async () => {
textarea!.focus();
textarea!.setSelectionRange(slashCaret, slashCaret);
textarea!.dispatchEvent(new window.Event("select", { bubbles: true }));
textarea!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "/", bubbles: true }));
await flushTimers();
});
await waitFor("middle slash command menu", () => Boolean(document.querySelector(".slashmenu")));
await act(async () => {
textarea!.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
let tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
if (!richInput || !tokens?.[0]) throw new Error("middle skill invocation did not render");
eq(richComposerTaskText(richInput), "请用检查", "first middle skill selection preserves surrounding text");
eq(
document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color"),
"#d59a2f",
"middle skill selection keeps its configured color",
);
const afterFirstToken = document.createRange();
afterFirstToken.setStartAfter(tokens[0]);
afterFirstToken.collapse(true);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(afterFirstToken);
await act(async () => {
dispatchPasteText(richInput!, "更多");
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared after middle-skill paste");
eq(richComposerTaskText(richInput), "请用更多检查", "paste after a middle skill preserves the entity and suffix");
eq(
richInput.querySelectorAll(".composer-invocation-token").length,
1,
"paste after a middle skill keeps the selected entity",
);
await appendRichComposerInput(richInput, " /review");
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("rich input disappeared before the second skill selection");
const queryAtEnd = document.createRange();
queryAtEnd.selectNodeContents(richInput);
queryAtEnd.collapse(false);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(queryAtEnd);
await act(async () => {
richInput!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
await flushTimers();
});
await waitFor("second skill menu at the end", () => Boolean(document.querySelector(".slashmenu")));
await act(async () => {
richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
eq(tokens?.length, 2, "a second skill can be inserted after existing text and an entity");
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!sendButton) throw new Error("send button did not render for middle skill submission");
await act(async () => {
sendButton.click();
await flushTimers();
});
eq(calls.structured[0]?.input, "请用更多检查", "middle skills submit the surrounding task text without slash tokens");
eq(
calls.structured[0]?.invocations.map((item) => item.name).join(","),
"writing-plans,review",
"multiple middle/end skills submit in visual order",
);
eq(calls.structured[0]?.invocations[0]?.offset, 2, "first middle skill keeps its text offset");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
let commandsCalls = 0;
@@ -1364,8 +1876,42 @@ console.log("\ncomposer goal toggle");
document.activeElement === textareaAfterEntityRemoval,
"removing the last entity hands focus to the textarea that replaces the rich input",
);
const undoEntityRemoval = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textareaAfterEntityRemoval.dispatchEvent(undoEntityRemoval);
await flushTimers();
});
eq(undoEntityRemoval.defaultPrevented, true, "Ctrl+Z restores a token removed by the rich composer");
ok(
document.querySelector(".invocation-display--composer") !== null,
"undoing the programmatic Backspace restores the selected skill",
);
const restoredRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!restoredRichInput) throw new Error("rich composer did not return after undoing token removal");
const redoEntityRemoval = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
restoredRichInput.dispatchEvent(redoEntityRemoval);
await flushTimers();
});
eq(redoEntityRemoval.defaultPrevented, true, "Ctrl+Shift+Z redoes rich token removal");
ok(
document.querySelector(".invocation-display--composer") === null,
"redoing the programmatic Backspace removes the selected skill again",
);
await replaceComposerDraft(rerender, 2002, "/writing-plans");
await waitFor("plain composer after replacing the restored skill", () => Boolean(document.querySelector("textarea")));
await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not return after removing the skill");
@@ -1802,5 +2348,153 @@ console.log("\ncomposer goal toggle");
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "review", description: "Review the current task", kind: "skill" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const sessionA = "session:project:/repo:topic-a:session-a";
const sessionB = "session:project:/repo:topic-b:session-b";
const { root, rerender } = await renderComposer({ sessionKey: sessionA });
await replaceComposerDraft(rerender, 5000, "x/review");
await waitFor("session A slash menu", () => Boolean(document.querySelector(".slashmenu")));
await rerender({ sessionKey: sessionB, insertRequest: null });
await replaceComposerDraft(rerender, 5001, "b");
const sessionBInput = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!sessionBInput) throw new Error("session B textarea did not render");
await act(async () => {
sessionBInput.focus();
sessionBInput.setSelectionRange(1, 1);
sessionBInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "b", bubbles: true }));
await flushTimers();
});
await rerender({ sessionKey: sessionA, insertRequest: null });
eq(
(document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
"x/review",
"switching back restores session A slash draft",
);
await waitFor(
"restored session A slash menu",
() => Boolean(document.querySelector(".slashmenu")),
);
ok(
document.querySelector(".slashmenu") !== null,
"restoring a draft recomputes slash completion from its end caret",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
mockApp({
Commands: async () => [
{ name: "review", description: "Review the current task", kind: "skill" },
],
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
});
const sessionA = "session:project:/repo:rich-topic-a:rich-session-a";
const sessionB = "session:project:/repo:rich-topic-b:rich-session-b";
const realRequestAnimationFrame = globalThis.requestAnimationFrame;
const queuedComposerFrames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (callback) => {
queuedComposerFrames.push(callback);
return queuedComposerFrames.length;
};
const { root, rerender } = await renderComposer({ sessionKey: sessionA });
await replaceComposerDraft(rerender, 6000, "/review");
await waitFor("session A first skill menu", () => Boolean(document.querySelector(".slashmenu")));
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("session A textarea did not render");
await act(async () => {
textarea.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("session A rich input did not render");
await appendRichComposerInput(richInput, " /review");
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("session A rich input disappeared");
const queryAtEnd = document.createRange();
queryAtEnd.selectNodeContents(richInput);
queryAtEnd.collapse(false);
document.getSelection()?.removeAllRanges();
document.getSelection()?.addRange(queryAtEnd);
await act(async () => {
richInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
await flushTimers();
});
await waitFor("session A second skill menu", () => Boolean(document.querySelector(".slashmenu")));
await rerender({ sessionKey: sessionB, insertRequest: null });
await replaceComposerDraft(rerender, 6001, "b");
await rerender({ sessionKey: sessionA, insertRequest: null });
await act(async () => {
let frameTime = 0;
while (queuedComposerFrames.length > 0) {
queuedComposerFrames.shift()?.(frameTime += 16);
}
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
if (!richInput) throw new Error("session A rich input was not restored");
eq(richComposerTaskText(richInput), " /review", "switching back restores the rich invocation draft");
await waitFor(
"restored session A rich slash menu",
() => Boolean(document.querySelector(".slashmenu")),
);
ok(
document.querySelector(".slashmenu") !== null,
"restoring a rich invocation draft recomputes slash completion from its end caret",
);
await act(async () => {
richInput.dispatchEvent(new window.KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
eq(
richInput?.querySelectorAll(".composer-invocation-token").length,
2,
"the restored rich slash query can select a second skill",
);
eq(richInput ? richComposerTaskText(richInput) : "", " ", "selecting the restored query replaces its slash token");
await rerender({ sessionKey: sessionB, insertRequest: null });
eq(
(document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
"b",
"switching away again preserves the other session draft",
);
await act(async () => {
root.unmount();
});
globalThis.requestAnimationFrame = realRequestAnimationFrame;
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,145 @@
// Run: tsx src/__tests__/composer-menu-viewport.test.ts
import { JSDOM } from "jsdom";
import {
COMPOSER_MENU_AVAILABLE_HEIGHT,
composerMenuAvailableHeight,
observeComposerMenuViewport,
} from "../lib/composerMenuViewport";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
ok(actual === expected, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function rect(top: number): DOMRect {
return {
left: 0,
top,
right: 600,
bottom: top + 120,
width: 600,
height: 120,
x: 0,
y: top,
toJSON: () => ({}),
} as DOMRect;
}
class TestResizeObserver {
static instance: TestResizeObserver | null = null;
readonly observed = new Set<Element>();
disconnected = false;
constructor(private readonly callback: ResizeObserverCallback) {
TestResizeObserver.instance = this;
}
observe(target: Element) {
this.observed.add(target);
}
unobserve(target: Element) {
this.observed.delete(target);
}
disconnect() {
this.disconnected = true;
this.observed.clear();
}
trigger() {
this.callback([], this as unknown as ResizeObserver);
}
}
console.log("\ncomposer menu viewport geometry");
const dom = new JSDOM(`<!doctype html><html><body>
<div id="layout">
<main id="chat"></main>
<footer id="footer"><div id="anchor"><div class="slashmenu"></div></div></footer>
<section id="terminal"></section>
</div>
</body></html>`, { pretendToBeVisual: true });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
let nextFrameId = 1;
const frames = new Map<number, FrameRequestCallback>();
const requestFrame = (callback: FrameRequestCallback) => {
const id = nextFrameId++;
frames.set(id, callback);
return id;
};
const cancelFrame = (id: number) => frames.delete(id);
dom.window.requestAnimationFrame = requestFrame;
dom.window.cancelAnimationFrame = cancelFrame;
globalThis.requestAnimationFrame = requestFrame;
globalThis.cancelAnimationFrame = cancelFrame;
function flushFrames() {
const queued = [...frames.entries()];
frames.clear();
for (const [, callback] of queued) callback(0);
}
const anchor = document.getElementById("anchor");
const terminal = document.getElementById("terminal");
if (!(anchor instanceof HTMLElement) || !(terminal instanceof HTMLElement)) {
throw new Error("missing geometry test elements");
}
let anchorTop = 567;
anchor.getBoundingClientRect = () => rect(anchorTop);
eq(composerMenuAvailableHeight(anchorTop), 553, "normal layout exposes the full 360px CSS cap");
eq(composerMenuAvailableHeight(10), 0, "tiny viewports never produce a negative max height");
const stop = observeComposerMenuViewport(anchor);
eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "553px", "initial layout is measured synchronously");
const observer = TestResizeObserver.instance;
if (!observer) throw new Error("ResizeObserver was not installed");
ok(observer.observed.has(terminal), "terminal sibling is observed because it can move the composer anchor");
// Reproduce the constrained layout from the review: terminal + tall composer
// move the anchor to y=207. The menu has 207 - 6 (anchor gap) - 8 (safe edge)
// = 193px, so its first rows remain on-screen and the rest scroll internally.
anchorTop = 207;
observer.trigger();
flushFrames();
eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "193px", "terminal expansion uses real space above the composer");
anchorTop = 307;
window.dispatchEvent(new Event("resize"));
flushFrames();
eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "293px", "viewport resize recomputes the geometry");
observer.trigger();
stop();
eq(frames.size, 0, "cleanup cancels a queued geometry frame");
eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "", "cleanup removes the temporary CSS variable");
ok(observer.disconnected, "cleanup disconnects layout observers");
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -56,6 +56,9 @@ eq(finalDeclaration(".composer-card--resized .composer__input", "height"), "100%
eq(finalDeclaration(".composer-card--resized .composer__input", "overflow-y"), "auto", "resized textarea scrolls internally");
eq(finalDeclaration(".composer-card--resized .composer__rich-input", "height"), "100%", "resized rich input fills the card's input area");
eq(finalDeclaration(".composer-card--resized .composer__rich-input", "overflow-y"), "auto", "resized rich input scrolls internally");
eq(finalDeclaration(".composer-invocation-caret-anchor", "min-width"), "1px", "invocation caret anchor keeps a compact hit target");
eq(finalDeclaration(".composer-invocation-caret-anchor", "width"), undefined, "invocation caret anchor can expand around WebView2-hosted text");
eq(finalDeclaration(".composer-invocation-caret-anchor", "overflow"), undefined, "invocation caret anchor does not clip WebView2-hosted text");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -8,6 +8,7 @@ import { Composer } from "../components/Composer";
import { invalidateCache } from "../lib/composerHistory";
import { composerDraftKeyForTab } from "../lib/composerDraftKey";
import { LocaleProvider } from "../lib/i18n";
import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
import {
SELECTED_TEXT_MAX_CHARS,
formatSelectedTextContext,
@@ -172,6 +173,24 @@ function textarea(): HTMLTextAreaElement {
return node;
}
function updateTextareaFromUser(
value: string,
inputType: "insertText" | "deleteContentBackward" | "historyUndo" | "historyRedo",
) {
const input = textarea();
input.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
if (!setter) throw new Error("textarea value setter is unavailable");
setter.call(input, value);
input.dispatchEvent(new window.InputEvent("input", {
bubbles: true,
data: inputType === "insertText" ? value.slice(-1) : null,
inputType,
}));
input.dispatchEvent(new window.Event("change", { bubbles: true }));
input.dispatchEvent(new window.KeyboardEvent("keyup", { key: "x", bubbles: true }));
}
function sendButton(): HTMLButtonElement {
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
if (!node) throw new Error("composer send button did not render");
@@ -422,24 +441,93 @@ console.log("\ncomposer session draft");
{
const dom = installDom();
const sent: Array<{ display: string; submit?: string }> = [];
const { root } = await renderComposer({
const { root, rerender } = await renderComposer({
onSend: (display, submit) => {
sent.push({ display, submit });
},
});
const typedPrefix = "typed before paste: ";
await rerender({ insertRequest: { id: 100, text: typedPrefix, mode: "replace" } });
const rawPaste = "error: failed to compile\r\nat loader.ts:10\r\nat run.ts:22";
const normalizedPaste = "error: failed to compile\nat loader.ts:10\nat run.ts:22";
const event = textPasteEvent(rawPaste);
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = 0;
input.selectionStart = input.selectionEnd = typedPrefix.length;
input.dispatchEvent(event);
await flushTimers();
});
eq(event.defaultPrevented, true, "short text paste is handled by React state");
eq(textarea().value, normalizedPaste, "short multiline paste is visible in the composer");
eq(textarea().value, typedPrefix + normalizedPaste, "short multiline paste is visible after existing text");
const undo = new window.KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true });
await act(async () => {
textarea().dispatchEvent(undo);
await flushTimers();
});
eq(undo.defaultPrevented, true, "Ctrl+Z handles the programmatic paste transaction");
eq(textarea().value, typedPrefix, "Ctrl+Z preserves the text typed before a short paste");
const redo = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redo);
await flushTimers();
});
eq(redo.defaultPrevented, true, "Ctrl+Shift+Z redoes the programmatic paste transaction");
eq(textarea().value, typedPrefix + normalizedPaste, "redo restores the short paste after existing text");
const undoAgain = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoAgain);
await flushTimers();
});
eq(textarea().value, typedPrefix, "a second undo returns to the pre-paste boundary");
await act(async () => {
updateTextareaFromUser(`${typedPrefix}X`, "insertText");
await flushTimers();
});
await rerender();
eq(textarea().value, `${typedPrefix}X`, "ordinary typing after paste undo updates the draft");
await act(async () => {
updateTextareaFromUser(typedPrefix, "deleteContentBackward");
await flushTimers();
});
await rerender();
const staleRedo = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(staleRedo);
await flushTimers();
});
eq(staleRedo.defaultPrevented, false, "a new native edit invalidates the programmatic redo transaction");
eq(textarea().value, typedPrefix, "stale redo cannot resurrect an earlier paste");
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = typedPrefix.length;
input.dispatchEvent(textPasteEvent(rawPaste));
await flushTimers();
});
await act(async () => {
sendButton().click();
@@ -447,8 +535,8 @@ console.log("\ncomposer session draft");
});
eq(sent.length, 1, "short multiline paste submits once");
eq(sent[0]?.display, normalizedPaste, "short multiline paste is preserved in display text");
eq(sent[0]?.submit, normalizedPaste, "short multiline paste is preserved in submit text");
eq(sent[0]?.display, typedPrefix + normalizedPaste, "short multiline paste is preserved in display text");
eq(sent[0]?.submit, typedPrefix + normalizedPaste, "short multiline paste is preserved in submit text");
await act(async () => {
root.unmount();
@@ -456,6 +544,460 @@ console.log("\ncomposer session draft");
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer();
const base = "base:";
const pasted = "paste";
await rerender({ insertRequest: { id: 101, text: base, mode: "replace" } });
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = base.length;
input.dispatchEvent(textPasteEvent(pasted));
await flushTimers();
});
eq(textarea().value, base + pasted, "interleaved-history test starts at the paste boundary");
await act(async () => {
updateTextareaFromUser(`${base}${pasted}X`, "insertText");
await flushTimers();
});
await rerender();
await act(async () => {
updateTextareaFromUser(base + pasted, "deleteContentBackward");
await flushTimers();
});
await rerender();
const undoNativeDelete = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoNativeDelete);
await flushTimers();
});
eq(
undoNativeDelete.defaultPrevented,
false,
"Ctrl+Z leaves a native deletion above the paste transaction to the browser",
);
eq(
textarea().value,
base + pasted,
"matching net text does not make the older paste transaction look newest",
);
await act(async () => {
updateTextareaFromUser(`${base}${pasted}X`, "historyUndo");
await flushTimers();
});
await rerender();
const undoNativeInsert = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoNativeInsert);
await flushTimers();
});
eq(
undoNativeInsert.defaultPrevented,
false,
"Ctrl+Z leaves the remaining native insertion to the browser",
);
await act(async () => {
updateTextareaFromUser(base + pasted, "historyUndo");
await flushTimers();
});
await rerender();
const undoPasteAfterNative = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoPasteAfterNative);
await flushTimers();
});
eq(
undoPasteAfterNative.defaultPrevented,
true,
"the paste transaction becomes undoable only after newer native edits are exhausted",
);
eq(textarea().value, base, "ordered undo eventually restores the pre-paste text");
const redoPasteBeforeNative = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoPasteBeforeNative);
await flushTimers();
});
eq(
redoPasteBeforeNative.defaultPrevented,
true,
"ordered redo restores the custom paste before its newer native edits",
);
eq(textarea().value, base + pasted, "custom redo returns to the browser redo boundary");
const redoNativeAfterPaste = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoNativeAfterPaste);
await flushTimers();
});
eq(
redoNativeAfterPaste.defaultPrevented,
false,
"newer native redo remains browser-owned after the paste redo",
);
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer();
await act(async () => {
textarea().dispatchEvent(textPasteEvent("first"));
await flushTimers();
});
await act(async () => {
updateTextareaFromUser("firstX", "insertText");
await flushTimers();
});
await rerender();
await act(async () => {
const input = textarea();
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(textPasteEvent("second"));
await flushTimers();
});
eq(textarea().value, "firstXsecond", "two custom pastes can surround a native edit");
const undoSecondPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoSecondPaste);
await flushTimers();
});
eq(undoSecondPaste.defaultPrevented, true, "the newest custom paste undoes first");
eq(textarea().value, "firstX", "undoing the second paste reaches its native boundary");
const undoInterleavedNative = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoInterleavedNative);
await flushTimers();
});
eq(
undoInterleavedNative.defaultPrevented,
false,
"the native edit between two custom pastes remains browser-owned",
);
await act(async () => {
updateTextareaFromUser("first", "historyUndo");
await flushTimers();
});
await rerender();
const undoFirstPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoFirstPaste);
await flushTimers();
});
eq(undoFirstPaste.defaultPrevented, true, "the older custom paste follows the native undo segment");
eq(textarea().value, "", "ordered undo reaches the start of the mixed timeline");
const redoFirstPaste = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoFirstPaste);
await flushTimers();
});
eq(redoFirstPaste.defaultPrevented, true, "ordered redo restores the first custom paste");
eq(textarea().value, "first", "redo reaches the native segment after the first paste");
const redoInterleavedNative = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoInterleavedNative);
await flushTimers();
});
eq(
redoInterleavedNative.defaultPrevented,
false,
"ordered redo returns the interleaved native edit to the browser",
);
await act(async () => {
updateTextareaFromUser("firstX", "historyRedo");
await flushTimers();
});
await rerender();
const redoSecondPaste = new window.KeyboardEvent("keydown", {
key: "Z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(redoSecondPaste);
await flushTimers();
});
eq(redoSecondPaste.defaultPrevented, true, "the second custom paste redoes after the native segment");
eq(textarea().value, "firstXsecond", "mixed custom/native redo restores the final state");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
await act(async () => {
resetCustomShortcuts();
await flushTimers();
});
saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true });
let yoloToggles = 0;
const { root } = await renderComposer({
onToggleYoloApprovalMode: () => {
yoloToggles += 1;
},
});
await act(async () => {
textarea().dispatchEvent(textPasteEvent("pasted"));
await flushTimers();
});
const undoPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoPaste);
await flushTimers();
});
eq(undoPaste.defaultPrevented, true, "legacy Ctrl+Z YOLO binding yields to composer undo while editing");
eq(textarea().value, "", "legacy Ctrl+Z YOLO binding still undoes the paste");
eq(yoloToggles, 0, "legacy Ctrl+Z YOLO binding does not toggle YOLO while editing");
await act(async () => {
saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true, shift: true });
await flushTimers();
});
const legacyRedo = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(legacyRedo);
await flushTimers();
});
eq(legacyRedo.defaultPrevented, true, "legacy Ctrl+Shift+Z YOLO binding yields to composer redo while editing");
eq(textarea().value, "pasted", "legacy Ctrl+Shift+Z YOLO binding still redoes the paste");
eq(yoloToggles, 0, "legacy Ctrl+Shift+Z YOLO binding does not toggle YOLO while editing");
await act(async () => {
saveCustomShortcut("toolApproval.yolo", { key: "u", ctrl: true });
textarea().dispatchEvent(new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
}));
await flushTimers();
});
eq(textarea().value, "", "Ctrl+Z prepares a custom redo transaction after YOLO is rebound");
const ctrlYRedo = new window.KeyboardEvent("keydown", {
key: "y",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(ctrlYRedo);
await flushTimers();
});
eq(ctrlYRedo.defaultPrevented, true, "Ctrl+Y redoes the paste when the YOLO shortcut is rebound");
eq(textarea().value, "pasted", "Ctrl+Y restores the programmatic paste");
eq(yoloToggles, 0, "Ctrl+Y does not toggle YOLO after that action is rebound");
await act(async () => {
resetCustomShortcuts();
await flushTimers();
});
const defaultCtrlY = new window.KeyboardEvent("keydown", {
key: "y",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(defaultCtrlY);
await flushTimers();
});
eq(defaultCtrlY.defaultPrevented, true, "default Ctrl+Y remains reserved for the YOLO shortcut");
eq(yoloToggles, 1, "default Ctrl+Y still toggles YOLO exactly once");
eq(textarea().value, "pasted", "the YOLO shortcut does not mutate composer history");
await act(async () => root.unmount());
resetCustomShortcuts();
dom.window.close();
}
{
const dom = installDom();
const { root, rerender } = await renderComposer({
tabId: "tab-a",
sessionKey: "session:project:/repo:topic-a:session-a",
});
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
await act(async () => {
textarea().dispatchEvent(textPasteEvent(longPaste));
await flushTimers();
});
ok(document.querySelector(".composer__pasted-label") !== null, "long paste creates a folded block");
const previewButton = document.querySelector<HTMLButtonElement>(".composer__pasted-actions button");
if (!previewButton) throw new Error("long paste preview button did not render");
await act(async () => {
previewButton.click();
await flushTimers();
});
ok(document.querySelector(".composer__pasted-preview") !== null, "long paste preview opens without changing text history");
await rerender({
tabId: "tab-b",
sessionKey: "session:project:/repo:topic-b:session-b",
insertRequest: { id: 101, text: "draft B", mode: "replace" },
});
const unrelatedUndo = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(unrelatedUndo);
await flushTimers();
});
eq(unrelatedUndo.defaultPrevented, false, "another draft does not inherit the paste undo transaction");
eq(textarea().value, "draft B", "Ctrl+Z in another draft leaves its text unchanged");
await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
ok(document.querySelector(".composer__pasted-preview") !== null, "source draft restores its open long-paste preview");
const foldedUndo = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(foldedUndo);
await flushTimers();
});
eq(foldedUndo.defaultPrevented, true, "source draft keeps its own folded-paste undo transaction");
eq(textarea().value, "", "Ctrl+Z removes the folded-paste label");
ok(document.querySelector(".composer__pasted-label") === null, "Ctrl+Z removes the folded block state");
ok(document.querySelector(".composer__pasted-preview") === null, "Ctrl+Z also clears preview state with the folded block");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const { root } = await renderComposer();
const longPaste = Array.from({ length: 20 }, (_, index) => `expanded ${index + 1}`).join("\n");
await act(async () => {
textarea().dispatchEvent(textPasteEvent(longPaste));
await flushTimers();
});
const expandButton = document.querySelectorAll<HTMLButtonElement>(".composer__pasted-actions button")[1];
if (!expandButton) throw new Error("long paste expand button did not render");
await act(async () => {
expandButton.click();
await flushTimers();
});
eq(textarea().value, longPaste, "expanding a folded paste restores its original text");
ok(document.querySelector(".composer__pasted-label") === null, "expanded paste no longer renders a folded label");
const undoExpand = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoExpand);
await flushTimers();
});
eq(undoExpand.defaultPrevented, true, "Ctrl+Z treats pasted-block expansion as a custom transaction");
ok(document.querySelector(".composer__pasted-label") !== null, "undoing expansion restores the folded block");
const undoLongPaste = new window.KeyboardEvent("keydown", {
key: "z",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
await act(async () => {
textarea().dispatchEvent(undoLongPaste);
await flushTimers();
});
eq(undoLongPaste.defaultPrevented, true, "a second Ctrl+Z undoes the original folded paste");
eq(textarea().value, "", "undoing the original folded paste restores the empty draft");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const saveStarted = deferred<void>();
@@ -577,7 +1119,7 @@ console.log("\ncomposer session draft");
textarea().setSelectionRange(2, 2);
const menuItems = await openComposerInputMenu();
await act(async () => {
menuItems[2]?.click();
menuItems[4]?.click();
await flushTimers();
});
await rerender({
@@ -624,7 +1166,7 @@ console.log("\ncomposer session draft");
textarea().setSelectionRange(1, 4);
const menuItems = await openComposerInputMenu();
await act(async () => {
menuItems[0]?.click();
menuItems[2]?.click();
await clipboardWriteStarted.promise;
});
await rerender({
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
}
const source = readFileSync(new URL("../components/MemoryPanel.tsx", import.meta.url), "utf8");
ok(source.includes("function memoryFactKey"), "Context Center must use a stable fact identity helper");
ok(source.includes("app.MemoryRevisionsForTab"), "Context Center must load revision history for the selected workspace");
ok(source.includes("app.RestoreMemoryRevisionForTab"), "Context Center must restore a selected revision");
ok(source.includes("view.lastRecall"), "Context Center must explain the last automatic recall");
ok(source.includes("view.conflicts"), "Context Center must explain project-over-global memory resolution");
ok(source.includes("view.instructionDiagnostics"), "Context Center must surface instruction diagnostics");
ok(source.includes("d.precedence"), "Context Center must show resolved instruction precedence");
console.log("context-center-contract: ok");
@@ -146,6 +146,31 @@ console.log("\ncontext window ring");
dom.window.close();
}
{
const dom = installDom();
installContextPanelMock(async () => contextPanelInfo(0));
const { root } = await renderRing({ turnCost: 0.125, currency: "$" });
const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
if (!button) throw new Error("missing context ring button");
await act(async () => {
button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
await wait(220);
});
const turnCostRow = [...document.querySelectorAll(".context-ring-popover__row")]
.find((row) => row.querySelector(".context-ring-popover__label")?.textContent === "turn cost");
eq(
turnCostRow?.querySelector(".context-ring-popover__value")?.textContent,
"$0.1250",
"turn cost uses the session currency before panel info is available",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const calls: string[] = [];
@@ -10,6 +10,7 @@ import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { ClearContextCard } from "../components/ClearContextCard";
import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
import { LocaleProvider } from "../lib/i18n";
import type { WireApproval } from "../lib/types";
@@ -73,13 +74,14 @@ function installDom(language = "en-US") {
console.log("\ndecision surface");
// Plan keeps one explicit start decision, but both visible choices are direct
// buttons. Exit/stop remain global controls rather than extra card branches.
// Plan exposes start, revise, and leave-without-executing as direct buttons so
// declining the current plan never traps the user in Plan mode.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
const answers: Array<[boolean, boolean, boolean]> = [];
const revisions: string[] = [];
let exits = 0;
const approval: WireApproval = {
id: "plan-1",
tool: "exit_plan_mode",
@@ -93,6 +95,7 @@ console.log("\ndecision surface");
approval={approval}
onAnswer={(a, s, p) => answers.push([a, s, p])}
onRevisePlan={(text) => revisions.push(text)}
onExitPlan={() => { exits += 1; }}
onStop={() => undefined}
/>
</LocaleProvider>,
@@ -101,11 +104,11 @@ console.log("\ndecision surface");
});
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
eq(actions.length, 2, "Plan has start and revise actions only");
eq(actions.length, 3, "Plan has start, revise, and exit-without-executing actions");
eq(document.querySelector(".prompt-shelf__actions")?.getAttribute("role"), "group", "Plan actions use button-group semantics");
ok(actions.every((action) => action.getAttribute("role") === "button"), "Plan actions are announced as buttons");
ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
ok(!document.body.textContent?.includes("Exit plan mode"), "Plan card hides the exit branch");
ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
await act(async () => {
@@ -115,6 +118,46 @@ console.log("\ndecision surface");
ok(document.querySelector(".plan-revision__input") != null, "Revise opens the inline editor in one click");
eq(answers.length, 0, "Opening revision does not start execution");
await act(async () => {
actions[2].click();
actions[2].click();
await flushTimers(220);
});
eq(exits, 1, "Exit without executing runs once and ignores a double click");
eq(answers.length, 0, "Exit without executing never approves plan execution");
eq(revisions.length, 0, "Exit without executing does not submit a plan revision");
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
let exits = 0;
await act(async () => {
root.render(
<LocaleProvider>
<ApprovalModal
approval={{ id: "plan-exit-key", tool: "exit_plan_mode", subject: "Plan ready" }}
onAnswer={() => undefined}
onExitPlan={() => { exits += 1; }}
onStop={() => undefined}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
await flushTimers(220);
});
eq(exits, 1, "number key 3 exits Plan without execution");
await act(async () => {
root.unmount();
});
@@ -577,6 +620,83 @@ console.log("\ndecision surface");
dom.window.close();
}
// Runtime decisions expose direct actions, so their displayed number keys must
// invoke the same callbacks while preserving normal typing behavior.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
const selected: string[] = [];
let cancelled = 0;
await act(async () => {
root.render(
<LocaleProvider>
<RuntimeDecisionCard
id="runtime-shortcuts"
title="Choose runtime action"
badge="Runtime"
meta="Select an action"
actions={["1", "2", "3"].map((key) => ({
key,
label: `Action ${key}`,
description: `Run action ${key}`,
onClick: () => selected.push(key),
}))}
onCancel={() => { cancelled += 1; }}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision number keys invoke their displayed actions");
await act(async () => {
root.render(
<LocaleProvider>
<RuntimeDecisionCard
id="runtime-shortcuts"
title="Choose runtime action"
badge="Runtime"
meta="Select an action"
actions={[{
key: "2", label: "Unavailable action", description: "Cannot run yet",
disabled: true, onClick: () => selected.push("disabled"),
}]}
onCancel={() => { cancelled += 1; }}
/>
</LocaleProvider>,
);
await flushTimers();
});
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision shortcuts do not invoke disabled actions");
const input = document.createElement("input");
document.body.append(input);
await act(async () => {
input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await flushTimers();
});
eq(selected.join(","), "1,2,3", "runtime decision shortcuts ignore editable fields");
eq(cancelled, 1, "Escape cancels the runtime decision");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Clear context: default cancel; clear requires explicit confirm; Escape cancels.
{
const dom = installDom();
@@ -75,7 +75,7 @@ function baseReport(runtime: boolean): CapabilityDiagnosticsReport {
plugins: 0,
mcp_servers: 1,
},
instructions: { docs: [{ path: "<workspace>/AGENTS.md", scope: "project", order: 1 }] },
instructions: { docs: [{ path: "<workspace>/AGENTS.md", scope: "project", directory: "<workspace>", depth: 0, order: 1 }] },
skills: {
roots: [{ path: "<workspace>/.reasonix/skills", scope: "project", status: "ok" }],
entries: [{ name: "demo", path: "<workspace>/.reasonix/skills/demo/SKILL.md", status: "winner" }],
@@ -0,0 +1,133 @@
// Run: tsx src/__tests__/footer-decision-overflow.test.ts
//
// Contract: the footer becomes a scroll/clip container ONLY while a decision
// surface (plan/tool approval, ask, clear-context) is shown. Scoping the
// overflow to `.footer--decision` keeps tall approval cards scrolling inside a
// capped footer (#7030) while leaving the composer state unclipped, so the
// upward-popping "/" and "@" menus (.slashmenu — position:absolute; bottom:
// calc(100% + 6px)) can expand to full height. An unconditional overflow-y on
// .footer clipped those menus to a thin strip above the input box (regression
// shipped after desktop-v1.18.0 via the #7030 fix in #7069).
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
// Strip comments so declaration parsing never matches prose inside them.
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
function matchingBlocks(selector: string): string[] {
const blocks: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rule.exec(styles)) !== null) {
const selectors = match[1].split(",").map((part) => part.trim());
if (selectors.includes(selector)) blocks.push(match[2]);
}
return blocks;
}
function finalDeclaration(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const block of matchingBlocks(selector)) {
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
let match: RegExpExecArray | null;
while ((match = declaration.exec(block)) !== null) {
value = match[1].trim();
}
}
return value;
}
console.log("\nfooter decision overflow");
// AC1 — composer state: the base footer is not a scroll/clip container, so the
// upward-popping menus are free to expand.
eq(finalDeclaration(".footer", "overflow-y"), undefined, "base .footer does not scroll/clip (composer menus can expand)");
eq(finalDeclaration(".footer", "max-height"), undefined, "base .footer is not height-capped in the composer state");
eq(finalDeclaration(".footer", "flex"), "0 0 auto", "base .footer keeps its flex sizing");
// AC2 — decision state: capped + scrollable so tall approval cards stay
// reachable and never slip behind the status bar (#7030 stays fixed).
eq(finalDeclaration(".footer--decision", "overflow-y"), "auto", "decision footer scrolls tall approval cards");
eq(finalDeclaration(".footer--decision", "max-height"), "calc(100% - var(--topicbar-height))", "decision footer is capped above the status bar");
// AC3 — the menu stays anchored above the composer, but its preferred 360px cap
// is now bounded by the real viewport space measured by Composer.
eq(finalDeclaration(".slashmenu", "position"), "absolute", ".slashmenu still anchors to the composer");
eq(finalDeclaration(".slashmenu", "bottom"), "calc(100% + 6px)", ".slashmenu still pops above the composer");
eq(finalDeclaration(".slashmenu", "max-height"), "min(360px, var(--composer-menu-available-height, 50vh))", ".slashmenu uses the measured available height");
// AC4 — App.tsx applies footer--decision exactly when a decision surface owns
// the footer, and the pre-existing footer--compact wiring stays intact.
eq(/decisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on decisionSurface");
eq(/terminalPanelOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
eq(/ref=\{composerWrapRef\}/.test(composerSource), true, "Composer measures the real menu anchor");
eq(/observeComposerMenuViewport\(anchor\)/.test(composerSource), true, "Composer observes geometry while a menu is open");
// AC5 — mutual-exclusivity guard: the composer host is hidden while a decision
// owns the footer, so a menu can never open in the decision state.
eq(finalDeclaration(".composer-decision-host--hidden", "display"), "none !important", "composer host stays hidden under a decision (menus cannot open)");
// AC6 — guard the detection gap noted in #7128's first review and covered by
// #7138: no scoped footer variant except the decision modifier may reintroduce
// clipping or a height cap around the composer menus.
function footerTarget(selectorPart: string): string {
return selectorPart.split(/[\s>+~]+/).pop() ?? "";
}
function targetsFooterElement(selectorPart: string): boolean {
return /\.footer(?!__)(--[a-z-]+)?(?=$|[.:\[])/.test(footerTarget(selectorPart));
}
function isDecisionTarget(selectorPart: string): boolean {
return footerTarget(selectorPart).includes(".footer--decision");
}
function declValue(body: string, property: string): string | undefined {
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
let value: string | undefined;
let match: RegExpExecArray | null;
while ((match = declaration.exec(body)) !== null) value = match[1].trim();
return value;
}
function footerClipOffenders(): string[] {
const offenders: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rule.exec(styles)) !== null) {
const selectorList = match[1].trim();
const parts = selectorList.split(",").map((part) => part.trim());
const footerParts = parts.filter(targetsFooterElement);
if (footerParts.length === 0 || footerParts.every(isDecisionTarget)) continue;
const body = match[2];
const clips =
(declValue(body, "overflow") ?? "visible") !== "visible" ||
(declValue(body, "overflow-y") ?? "visible") !== "visible" ||
(declValue(body, "overflow-x") ?? "visible") !== "visible";
if (clips || declValue(body, "max-height") !== undefined) offenders.push(selectorList);
}
return offenders;
}
eq(footerClipOffenders().join(", "), "", "no footer rule except .footer--decision clips or caps the footer");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,138 @@
// Run: tsx src/__tests__/github-link-rendering.test.tsx
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { classifyLinkIcon, parseGitHubLink } from "../components/githubLink";
import { RichMarkdownLink } from "../components/githubLink";
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
eq(Boolean(value), true, label);
}
function eq(actual: unknown, expected: unknown, label: string) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(
` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`,
);
failed += 1;
}
}
console.log("\ngithub link rendering");
eq(parseGitHubLink("https://github.com/esengine/DeepSeek-Reasonix/issues/6856"), {
kind: "issue",
owner: "esengine",
repo: "DeepSeek-Reasonix",
value: "6856",
compactLabel: "#6856",
}, "recognizes GitHub issue links");
eq(parseGitHubLink("https://github.com/esengine/DeepSeek-Reasonix/pull/123?diff=split"), {
kind: "pull",
owner: "esengine",
repo: "DeepSeek-Reasonix",
value: "123",
compactLabel: "PR #123",
}, "recognizes GitHub pull request links with query parameters");
eq(parseGitHubLink("https://github.com/esengine/DeepSeek-Reasonix/commit/abcdef1234567890"), {
kind: "commit",
owner: "esengine",
repo: "DeepSeek-Reasonix",
value: "abcdef1234567890",
compactLabel: "abcdef1",
}, "recognizes GitHub commit links");
eq(parseGitHubLink("https://github.com/esengine/DeepSeek-Reasonix"), null, "leaves repository links unchanged");
eq(parseGitHubLink("http://github.com/esengine/DeepSeek-Reasonix/issues/1"), null, "rejects insecure GitHub links");
eq(parseGitHubLink("https://example.com/issues/6856"), null, "leaves non-GitHub links unchanged");
eq(parseGitHubLink("javascript:alert(1)"), null, "does not enhance unsafe links");
eq(classifyLinkIcon("https://github.com/esengine/DeepSeek-Reasonix"), "github", "adds a GitHub icon to repository links");
eq(classifyLinkIcon("https://example.com/docs"), "external", "adds an external-link icon to web links");
eq(classifyLinkIcon("http://localhost:5173/docs"), "external", "adds an external-link icon to HTTP links");
eq(classifyLinkIcon("mailto:[email protected]"), "mail", "adds a mail icon to email links");
eq(classifyLinkIcon("./docs/GUIDE.md"), null, "leaves relative links without an icon");
eq(classifyLinkIcon("#section"), null, "leaves page fragments without an icon");
eq(classifyLinkIcon("javascript:alert(1)"), null, "does not add icons to unsafe protocols");
function renderLink(href: string, label: string = href) {
const markup = renderToStaticMarkup(
createElement(RichMarkdownLink, { href, children: label }),
);
return new JSDOM(markup).window.document.querySelector("a");
}
{
const href = "https://github.com/esengine/DeepSeek-Reasonix/pull/6976";
const link = renderLink(href);
const label = link?.querySelector(".md-rich-link__label");
eq(label?.textContent, href, "keeps the original GitHub URL in selectable DOM text");
eq(label?.getAttribute("data-display-label"), "PR #6976", "exposes the compact visual PR label");
eq(
link?.getAttribute("aria-label"),
"GitHub esengine/DeepSeek-Reasonix pull request #6976",
"gives compact PR links a contextual accessible name",
);
ok(link?.querySelector("svg[aria-hidden=\"true\"]"), "hides the decorative GitHub icon from assistive technology");
const selection = link?.ownerDocument.defaultView?.getSelection();
const range = link?.ownerDocument.createRange();
if (link && selection && range) {
range.selectNodeContents(link);
selection.removeAllRanges();
selection.addRange(range);
}
eq(selection?.toString(), href, "copies the original URL instead of the compact visual label");
}
{
const link = renderLink(
"https://github.com/esengine/DeepSeek-Reasonix/issues/6856",
"Readable issue title",
);
eq(link?.textContent, "Readable issue title", "preserves an explicit Markdown link label");
eq(
link?.querySelector(".md-rich-link__label")?.hasAttribute("data-display-label"),
false,
"does not compact explicit Markdown link labels",
);
eq(link?.hasAttribute("aria-label"), false, "does not override an explicit accessible link label");
}
{
const link = renderLink("https://example.com/a/very/long/documentation/path");
ok(link?.classList.contains("md-rich-link--external"), "renders ordinary HTTPS destinations as rich external links");
ok(link?.querySelector("svg[aria-hidden=\"true\"]"), "renders a decorative external-link icon");
}
{
const relative = renderLink("./docs/GUIDE.md", "Guide");
eq(relative?.className, "", "leaves relative links without rich-link styling");
eq(relative?.querySelector("svg"), null, "leaves relative links without an icon");
}
{
const styles = readFileSync(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8");
ok(
/\.md \.md-rich-link\s*\{[^}]*white-space:\s*normal;/.test(styles),
"allows rich links to wrap on narrow layouts",
);
ok(
/\.md \.md-rich-link__label\s*\{[^}]*min-width:\s*0;[^}]*overflow-wrap:\s*anywhere;/.test(styles),
"lets long unbroken link labels shrink and wrap",
);
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,109 @@
// Run: tsx src/__tests__/goal-action-errors.test.tsx
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { useGoalActionHandler } from "../lib/goalAction";
import { ToastProvider } from "../lib/toast";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
console.log("\ngoal action bridge errors");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
const unhandled: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => unhandled.push(reason);
const onWindowUnhandledRejection = (event: PromiseRejectionEvent) => unhandled.push(event.reason);
process.on("unhandledRejection", onUnhandledRejection);
window.addEventListener("unhandledrejection", onWindowUnhandledRejection);
function Probe() {
const { runGoalAction } = useGoalActionHandler();
const run = (label: string) => {
runGoalAction(async () => {
throw new Error(`${label} bridge failed`);
});
};
return (
<>
<button type="button" data-action="stop" onClick={() => run("stop goal")}>stop</button>
<button type="button" data-action="mode" onClick={() => run("switch mode")}>mode</button>
<button type="button" data-action="resync" onClick={() => run("background goal resync")}>resync</button>
</>
);
}
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(<ToastProvider><Probe /></ToastProvider>);
});
for (const action of ["stop", "mode", "resync"]) {
await act(async () => {
document.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)?.click();
await flushPromises();
});
}
const errors = Array.from(document.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent);
ok(errors.includes("stop goal bridge failed"), "rejected Stop Goal action shows an error toast");
ok(errors.includes("switch mode bridge failed"), "rejected mode action shows an error toast");
ok(errors.includes("background goal resync bridge failed"), "rejected background Goal resync shows an error toast");
ok(unhandled.length === 0, "handled Goal action rejections do not emit unhandledrejection");
const here = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
ok(
/runGoalAction\(\(\) => applyCollaborationMode\(collaborationMode === "plan" \? "normal" : "plan"\)\)/.test(appSource),
"mode shortcut routes through the rejection handler",
);
ok(
/runGoalAction\(async \(\) => \{[\s\S]{0,260}setControllerComposerProfileForTab\([\s\S]{0,260}propagateError: true/.test(appSource),
"background Goal resync routes through the rejection handler",
);
ok(appSource.includes("onClearGoal={clearGoalFromUi}"), "Composer Stop Goal routes through the rejection handler");
ok(appSource.includes("onSetCollaborationMode={setCollaborationModeFromUi}"), "Composer mode changes route through the rejection handler");
ok(/if \(model\) \{\s*await switchModel\(model\[1\]\);/.test(appSource), "/model awaits Goal restoration failures");
ok(
/await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource),
"failed Goal bridge calls cannot patch local Goal state or user intent",
);
await act(async () => {
root.unmount();
});
process.off("unhandledRejection", onUnhandledRejection);
window.removeEventListener("unhandledrejection", onWindowUnhandledRejection);
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
Loaded 100 of 883 files, more files were not shown because too many files have changed in this diff. Show more