Compare commits

...
845 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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
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
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
SivanCola b8f715b546 Drain lazy MCP cache writes on close / 关闭时等待 Lazy MCP 缓存写入 (#6840)
* fix(plugin): drain lazy schema writes on close

Problem:
Formal release tests intermittently failed while removing plugin cache temp directories because a lazy schema cache writer was still running.

Root cause:
Lazy MCP startup launched SaveCachedSchema in an untracked goroutine, so Host.Close could return before the detached write finished.

Fix:
Register lazy schema persistence with the Host background-write WaitGroup and add a deterministic close-wait regression test.

Verification:
- focused lazy lifecycle tests, 50 runs
- go test ./internal/plugin -count=5
- go test -race ./internal/plugin
- two consecutive REASONIX_RELEASE_CACHE_GUARD=1 go test ./... -count=1

* fix(plugin): close remaining cache write races

Problem:
PR review found that the synchronous lazy cache-hit path could still write after Host.Close returned, while Windows CI intermittently observed the SSE concurrency assertion before its handler published the active count.

Root cause:
The cache-hit path ended its deferred-spawn lifecycle before persisting the schema, and the SSE test closed its started signal before updating maxPosts.

Fix:
Queue cache-hit schema persistence before ending the deferred spawn, and publish the SSE started signal only after the concurrency counters are visible.

Verification:
- focused lazy lifecycle tests, 50 runs
- SSE concurrency regression test, 5000 runs
- focused race tests, 20 runs
- go test -race ./internal/plugin
- go test ./internal/plugin -count=10
- REASONIX_RELEASE_CACHE_GUARD=1 go test ./... -count=1
- go vet ./... && go build ./...
- Windows/amd64 plugin test cross-compilation
2026-07-22 23:01:27 +08:00
SivanCola 5778e919d5 test(frontend): restore release typecheck gate (#6837)
Problem:
The formal frontend test gate failed TypeScript checking before the 1.17.19 release.

Root cause:
The MCP install mock dereferenced optional Wails bindings without narrowing them, and an obsolete textarea helper remained after the test flow changed.

Fix:
Narrow the mocked App binding before delegation and remove the unused helper.

Verification:
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- pnpm test:all
- pnpm build
2026-07-22 14:27:03 +00:00
erphenheimerandSivanCola 9536edce8a fix(todo): add canonical todo state to MetaForTab so frontend reads authoritative server-side state / 修复待办面板状态:MetaForTab 传规范待办状态 (#6774)
* fix(todo): add canonical todo state to MetaForTab so frontend reads authoritative server-side state

The frontend todoEntry previously only parsed the last todo_write tool call's args from state.items, which could show stale state when:
- Synthetic host-advance events from complete_step were not yet committed to items
- Session reload caused compactArchivedToolItems to truncate non-canonical todo args

Fix: add CanonicalTodos field to Meta struct, populate it from the Controller's authoritative todoState in MetaForTab, so the frontend always has access to the correct canonical task list independent of tool call event processing.

* fix(todo): frontend reads canonicalTodos from Meta for authoritative task list

The frontend todoEntry previously only parsed the last todo_write tool
call's args from state.items. Add fallback: if state.meta.canonicalTodos
(from MetaForTab) is available and non-empty, prefer it over parsing
tool call args. This ensures the frontend always shows the correct
canonical todo state even after session reload or when synthetic
host-advance events are not yet committed to items.

* fix: normalize app.go line endings to LF for gofmt compliance

* fix: gofmt alignment + types.ts Todo type import

- Fix gofmt alignment issue in Meta struct: CanonicalTodos field
  tag alignment (extra spaces before backtick)
- Add import type { Todo } from './tools' to types.ts so the
  canonicalTodos field in Meta interface can reference Todo[]
  TypeScript type

* fix: add missing time import

* Revert "fix: add missing time import"

This reverts commit a21b24cadca43796933d520788cecacf4aac8657.

* fix(todo): preserve canonical state across session transitions

Problem:
Canonical todo status-only updates could be discarded by frontend meta equality, and session resets could retain stale todos. Empty canonical lists were also indistinguishable from an unavailable controller snapshot.

Root cause:
The Meta contract used omitempty on a slice, sameMeta ignored canonicalTodos, optimistic tab metadata dropped it, and session resume or rewind paths did not refresh authoritative todo metadata after resetting transcript state.

Fix:
Preserve unavailable, empty, and non-empty todo states across the Wails boundary; compare canonical todo content in meta equality; clear session-scoped todo metadata on reset; refresh it after session transitions; and add focused wire, reducer, visibility, new-session, and resume regression coverage.

Verification:
- cd desktop && go test ./...
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:typecheck
- git diff --check

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

* fix(todo): guard canonical state across runtimes

Problem:
Remote workbench sessions dropped canonical Todos, and a Meta response started before a session transition could restore the previous session's Todo state after the reset.

Root cause:
Frontend Meta reads had no per-tab latest-request sequencing tied to session generations, while the native workbench projection ignored SessionSnapshot.Todos.

Fix:
Centralize guarded Meta reads, invalidate them on every session transition, project validated remote Todos with unavailable/empty/value semantics, and cover both race and remote projection paths.

Verification:
- go test ./...
- pnpm test
- pnpm typecheck
- pnpm test:typecheck
- pnpm build

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-22 22:25:20 +08:00
JesonChouandSivanCola 06e41c7826 fix(plugin): MCP stdio subprocess CWD resolves against wrong project root (#6778) / 修复 MCP 子进程工作目录解析到错误项目根目录 (#6819)
* fix(plugin): MCP stdio subprocess CWD falls back to WorkspaceRoot

When Spec.Dir is empty (the default for user-configured plugins), the
stdio subprocess inherits the parent process CWD. In the Wails desktop
app running multiple projects, that CWD is the first project listed in
desktop-projects.json, not the directory containing .mcp.json. Relative
config paths (e.g. --config-file ssh-config.json) then resolve against
the wrong project root.

Fix: newStdioTransport now falls back to Spec.WorkspaceRoot when Dir is
empty, so every project-scoped MCP server resolves relative paths
against the correct project root.

Fixes #6778.

* fix(plugin): align project MCP cwd authorization

Problem: Project-scoped stdio MCP cwd fallback could authorize a different relative executable than the one launched, while also changing cwd for user-installed servers.

Root cause: Launch and authorization resolved relative commands against different directories, and WorkspaceRoot was treated as process cwd without checking project provenance.

Fix: Centralize stdio working-directory selection, limit the fallback to repository-declared servers, resolve relative executables against that directory before hashing and launching, and bind the directory to the launch identity.

Verification: go test ./internal/plugin -count=1; go test ./internal/boot -count=1; env -u DEEPSEEK_API_KEY go test ./...; go vet ./...; Windows and Linux test cross-compilation; git diff --check.

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-22 22:09:17 +08:00
SivanCola 31f037d07a feat(mcp): make trusted servers zero-config and reliable / 让可信 MCP 服务零配置且可靠可用 (#6829)
* fix(mcp): make trusted servers zero-config and reliable

Run authorized stdio servers on the host by default while retaining private state directories, bounded lifecycle cleanup, lazy schema restoration, and single-flight startup.

Add durable activation, transactional readiness and rollback, CLI candidate probing, and inherited MCP access for child agents. Include a real chrome-devtools-mcp release smoke test.

* feat(desktop): reduce MCP install to one input

Accept a command, URL, or JSON definition in the common install path, surface structured readiness and authentication states, and keep transport and timeout controls in the advanced edit flow.

* feat(desktop): unify MCP quick and advanced setup

Problem: The zero-config MCP installer removed useful configuration controls and left the add-server page feeling too sparse.

Design: Keep quick install as the default, restore manual and JSON modes in the same editor, carry pasted definitions into advanced modes, and make the automatic install outcomes explicit.

Verification: npm run typecheck; npx tsx src/__tests__/capabilities-panel-actions.test.ts; npm run check:css; npm run build; git diff --check.

* fix(plugin): remove obsolete deferred wrapper

Problem: golangci-lint reported Host.addDeferred as an unused private method.

Root cause: Lazy startup now calls EnsureConnectedWithLifecycle directly, but the old wrapper remained after the single-flight migration.

Fix: Remove the dead wrapper and its stale comment without changing runtime behavior.

Verification: go test ./internal/plugin; go test -race ./internal/plugin; scripts/check-cache-impact.sh; git diff --check.

* fix(desktop): remove obsolete MCP intent helper

Problem: The desktop golangci-lint job reported mcpStartIntent as an unused private function.

Root cause: Legacy startIntent fields are now derived in withPluginConfigInWorkspace, but the previous helper remained after the activation-state migration.

Fix: Remove the dead helper while preserving the derived compatibility fields.

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

* fix(mcp): preserve trust and on-demand boundaries

Problem: Explicit subagent tool allowlists could be widened by MCP inheritance, host-session readiness probes owned successful stdio processes, durable enable still connected eagerly, activation updates could clobber each other, quick names followed trailing arguments, and Windows asserted POSIX mode bits.

Root cause: MCP inheritance bypassed the normal registry filter; the short probe context was reused as process lifetime; enable reused the live-connect API; activation stores only locked per instance; name derivation scanned from the command tail; and the permission test was not platform-aware.

Fix: Keep explicit allowlists authoritative while preserving default MCP inheritance, split ExtraPlugin call and lifetime contexts, add cache-backed on-demand registration, serialize activation transactions with a cross-process lock, derive quick names from launcher operands in desktop and CLI, and scope mode-bit assertions to POSIX.

Verification: go test -race ./internal/agent ./internal/boot ./internal/control ./internal/config; env -u DEEPSEEK_API_KEY go test ./...; cd desktop and run go test ./... plus go vet ./...; frontend MCP actions, typecheck, build, and CSS checks; go vet ./...; ./scripts/cache-guard.sh; git diff --check.
2026-07-22 21:42:47 +08:00
SivanCola d9dd4aa0e5 fix(registry): re-review publisher updates / 发布者更新重新进入审核 (#6830)
* fix(registry): re-review publisher updates

Problem:
Publishers could replace the source, manifest, or metadata of an active package while it stayed live and retained its verified badge.

Root cause:
Registry moderation was triggered only when the public package kind changed, although every accepted republish creates a new immutable package version.

Fix:
Return every non-admin package update to pending review, clear verification, and cover active, hidden, rejected, and trusted-admin update paths.

Verification:
- workers/crash-report: npm test (35 passed)
- workers/crash-report: npm run typecheck
- git diff --check

* fix(registry): fence approvals to reviewed revision

Problem:
A stale moderation page could approve a package version submitted after the reviewer loaded the queue.

Root cause:
Approval requests carried only the slug and action, while both admin backends performed unconditional status updates.

Fix:
Bind approvals to the reviewed version, update timestamp, and status with one atomic D1 conditional update. Stale or old-page requests now fail closed across both admin interfaces.

Verification:
- worker tests: 41 passed
- worker typecheck
- site tests: 26 passed
- site production build
- git diff --check
2026-07-22 21:24:27 +08:00
SivanCola d97dfdcfbf feat(registry): support plugin submissions / 支持插件类型提交 (#6816)
* feat(registry): support plugin submissions

Problem:
The community registry exposed only skill and MCP package kinds even though the installer already supports plugin packages.

Root cause:
Plugin was absent from the site form, query and publish schemas, shared package types, and republish persistence.

Fix:
Add plugin as a first-class registry kind, require installable GitHub plugin sources, persist kind changes on republish, and expose plugin filtering and guidance in the site and moderation UI.

Verification:
- npm test (workers/crash-report)
- npm run typecheck (workers/crash-report)
- npm test (site)
- npm run build (site)
- Browser verification at /skills/

* fix(registry): re-review package reclassifications

Problem:
Package owners could reclassify an active or verified package as a plugin without returning it to moderation, and the publish API accepted plugin installers hidden behind skill or MCP kinds.

Root cause:
Republish persisted kind without coupling the change to status and verification, while validation only checked installer mismatches when kind itself was plugin.

Fix:
Return publisher-initiated active kind changes to pending, clear verification on every non-admin kind change, and require explicit installer kinds to match the public package kind.

Verification:
- npm test (workers/crash-report): 29 passed
- npm run typecheck (workers/crash-report)
- git diff --check

* fix(registry): disclose compatible plugin formats

Problem:
The registry publish form and API error claimed plugin submissions required a Reasonix-native manifest, even though the installer also accepts Codex and Claude plugin packages.

Root cause:
The submission guidance was written for the first native manifest path and was not updated when compatibility parsers and Claude marketplace support were added.

Fix:
List the native, Codex, Claude, and supported Claude marketplace manifests in both the form guidance and server validation error, with contract tests covering every format.

Verification:
- worker test suite: 30 tests passed
- worker TypeScript typecheck passed
- site test suite: 24 tests passed
- Astro production build passed
- git diff --check passed

* fix(registry): pin installs to declared package kind

Problem:
- Registry skill and MCP submissions could retain installKind=auto, while copied install requests omitted the declared kind.

Root cause:
- Only plugin submissions normalized auto, and the API echoed legacy installer values independently of the public package kind.

Fix:
- Normalize every submission and API response to the declared kind, and preserve that kind in publish and copy flows.

Verification:
- workers/crash-report: npm test; npm run typecheck
- site: npm test; npm run build
- git diff --check

* fix(registry): harden plugin source validation

Problem:
Plugin submissions could preserve control characters in copied install prompts, and non-canonical GitHub URLs could select paths outside the cloned repository.

Root cause:
The registry accepted any GitHub URL with two path segments while the Go installer ignored unsupported URL tails and joined tree paths without a containment check.

Fix:
Reject unsafe source characters and non-repository GitHub shapes, make the shared Go parser fail closed, and verify selected plugin roots remain inside the resolved clone.

Verification:
go test ./internal/installsource
env -u DEEPSEEK_API_KEY go test ./...
go vet ./...
npm test && npm run typecheck (workers/crash-report)
npm test && npm run build (site)
2026-07-22 19:20:40 +08:00
SivanColaandHaoyueQin 5888d5df75 Fix theme editor save actions and responsive layout / 修复主题编辑器保存操作与响应式布局 (#6788) (#6821)
* fix(desktop): keep theme editor actions accessible

Problem:
- The theme editor could hide its Save and Apply actions inside the settings layout on Windows and compress controls in Creation mode.
- The default code-viewer contract could race React 19 lazy resolution.

Root cause:
- The fixed editor overlay remained inside a containing block and responded to the global viewport instead of its own available width.
- The code-viewer assertion assumed one task flush was enough for Suspense to resolve.

Fix:
- Portal the editor to the document body, retain accessible modal focus behavior, and use editor-scoped container queries with stable action placement.
- Let segmented controls shrink and wrap at narrow editor widths.
- Wait for the lazy default code viewer before asserting its DOM contract.

Verification:
- pnpm test
- pnpm build
- Browser validation at the reported Windows flow and a 957x675 viewport

* fix(desktop): contain theme editor escape handling

Problem:
Saving a theme left Escape unowned by the nested editor, allowing the outer Settings modal to close. Safe-area segmented controls also used a narrower selector than the related contribution in #6726.

Root cause:
The busy branch returned before preventing propagation, and the document listener captured stale busy and cancel closures. The segmented button rule was scoped only to direct .set-seg children.

Fix:
Always consume Escape while the editor is mounted, read live busy and cancel values through refs, and add a runtime modal-ownership regression. Adopt the unified setting-row selector from #6726.

Verification:
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- pnpm test
- pnpm build
- git diff --check

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

---------

Co-authored-by: HaoyueQin <[email protected]>
2026-07-22 19:11:56 +08:00
SivanCola 9fafac05f4 fix(provider): classify MiniMax content-review errors (#6824)
Problem:
MiniMax content-review failures 1026 and 1027 arrive as HTTP 422, so Reasonix mislabeled them as invalid parameters and discarded the provider trace identifier.

Root cause:
Provider errors were classified only by HTTP status, and APIError retained the response body but not trace_id response headers.

Fix:
Recognize MiniMax input/output content-review responses, show localized recovery guidance without blindly retrying 422 requests, parse MiniMax base_resp messages, and preserve bounded trace IDs for diagnostics.

Verification:
- go test ./internal/provider ./internal/control ./internal/i18n
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check
2026-07-22 18:45:21 +08:00
ecd66a30f9 feat: show selected text card in chat bubble with formatted preview选中引用文本卡片显示 (#6813)
* feat: show selected text card in chat bubble with formatted preview

Display the selected chat/code text as an expandable card in the sent
message, reusing the existing paste-block mechanism. The input-area
tooltip now renders with Markdown for syntax-highlighted preview.

Changes:
- selectedTextContext.ts: add SELECTION_LABEL_RE and formatSelectionLabel
- Composer.tsx: add selection labels to displayRefs, begin/end markers,
  and Markdown tooltip for input cards
- ComposerContextCard.tsx: tooltipLabel type string -> ReactNode
- Message.tsx: extend PASTE_LABEL_RE to match selection labels, use
  Markdown for expanded paste/selection content
- styles.css: .msg-pasted-expanded .md white-space, .tooltip .md pre
- test: update display assertion for new label format

* fix(desktop): keep selected context cards display-only

Problem:
Selected-text cards duplicated quoted content into provider-visible submit bytes, used truncated labels as unique identities, and rendered existing pasted blocks as Markdown.

Root cause:
The card UI reused pasted-text begin/end markers stored inside submitText instead of reading the selected-context payload that was already persisted for the turn.

Fix:
Recover selection cards from the existing safe JSON payload, correlate duplicate labels by occurrence, sanitize file-name brackets, render code with the highlighted code viewer, and keep pasted blocks literal.

Verification:
- pnpm test
- pnpm build
- pnpm typecheck
- pnpm test:typecheck
- ./scripts/cache-guard.sh
- Browser-verified duplicate chat cards, bracketed file names, code indentation/highlighting, Markdown chat content, and literal pasted text

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

* fix(desktop): preserve selected context through edits

Problem:
Editing a message with selected-context cards removed the quoted context while leaving inert labels, and label-like prose could be consumed as a card.

Root cause:
Display labels were parsed with a global regex and edit replay treated the selected-context suffix as part of the visible body.

Fix:
Derive exact label suffixes from validated JSON metadata, isolate generated labels from editable text, and split/reappend the selected-context block during replay.

Verification:
- targeted selected-context and edit-replay tests
- pnpm build
- pnpm test:typecheck
- ./scripts/cache-guard.sh
- browser-verified selected-context edit preservation

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

---------

Co-authored-by: SauronSkywalker <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-22 18:39:39 +08:00
SivanCola d6a5548398 Refine Auto plan decision boundaries / 精简 Auto 计划决策边界 (#6817)
* feat(auto): reserve prompts for plan decisions

Problem:
Auto Guard interrupted ordinary execution based on inferred risk, while material plan changes did not present a focused, neutral decision surface.

Root cause:
Recovery policy mixed execution-risk classification with user-owned strategy and scope choices, and recovery events did not carry enough plan context for clients to show the transition.

Fix:
Keep ordinary Auto execution under existing permission and sandbox policy, review structural plan transitions independently, stop repeated technical failures without asking for risk consent, and present optional before/after plan deltas across Desktop, TUI, and bot clients.

Verification:
- go test ./internal/eventwire ./internal/recovery ./internal/agent ./internal/control ./internal/cli ./internal/bot -count=1
- pnpm typecheck
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/decision-surface.test.tsx
- pnpm check:css
- go run ./cmd/remote-protocol-gen -root . -check
- ./scripts/cache-guard.sh

* Fix Auto plan decision follow-through

Problem:

Adopting an Auto plan transition could still fail todo continuity, Plan plus Auto could ask twice for the same plan, and the CLI preselected the adopt choice.

Root cause:

The recovery decision carried no one-call replacement authorization, plan-transition review ignored active Plan mode, and approval selection always started at the first row.

Fix:

Carry a host-only replacement authorization into the approved todo_write call, preserve completed task history, use the existing Plan exit gate during planning, and require an explicit CLI plan choice.

Verification:

Focused agent, recovery, todo, evidence, and CLI tests passed. go vet ./... passed. The cache guard and cache-impact check passed; the optional live provider probe passed with the invalid machine credential unset.
2026-07-22 18:19:03 +08:00
ffff2004andSivanCola 5bea38e530 fix(config): 修复permissions配置更新时的数据丢失问题 (#6784)
* fix(config): preserve permission policy when persisting approvals

Replace the allow-only section rewrite with a locked, strict, targeted mutation so concurrent approvals cannot drop existing policy fields or comments.\n\nRefs #2

* fix(config): respect TOML multiline strings in permission updates

Problem: Permission persistence could mistake section examples inside multiline strings for real config and report success after editing prompt text.

Root cause: The line-based section scanner did not preserve multiline basic or literal string state across lines, and candidate output was written without semantic validation.

Fix: Track TOML string forms while locating structural section and key lines, then require the candidate to parse and contain the exact requested allow rules before atomic replacement.

Verification: go test ./internal/config ./internal/boot ./internal/control; focused race tests for permission persistence; env -u DEEPSEEK_API_KEY go test ./...; go vet ./...; git diff --check.

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-22 17:20:02 +08:00
SivanCola d61b087dd9 Fix Windows portable entrypoint collision / 修复 Windows 便携包入口冲突 (#6806)
* fix(desktop): prevent Windows portable entrypoint collision

Problem:
Windows portable staging used Reasonix.exe for the GUI launcher and reasonix.exe for the bundled CLI. On case-insensitive Windows filesystems, the CLI silently overwrote the launcher, so desktop shortcuts opened the CLI.

Root cause:
The packaging contract was checked only as source strings and never validated against the final Windows staging directory.

Fix:
Give the internal Windows CLI sidecar the distinct reasonix-cli.exe name, update installer, updater, and remote lookup contracts, and verify the exact portable file set plus launcher identity before archiving.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- cd desktop && go vet ./... && go build ./...
- go vet ./... && go build ./...
- Windows PE cross-build and portable verifier simulation

* fix(repair): accept renamed Windows CLI sidecar

Problem:
Windows desktop updates record reasonix-cli.exe in the pending release unit, but the repair validator rejects that renamed sidecar when the transaction is read back.

Root cause:
The packaging and updater contracts were updated to the collision-free filename without extending the repair transaction allowlist.

Fix:
Allow reasonix-cli.exe only as a non-primary release sibling and cover the complete Windows release unit through a PrepareFileUpdate-to-ReadPendingUpdate round trip.

Verification:
- go test ./internal/repair
- go test -race ./internal/repair
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- git diff --check
2026-07-22 15:58:16 +08:00
par73e eac67a7fbf feat(desktop): enhance workspace file tree (#6807) 2026-07-22 15:49:11 +08:00
14e97c92a2 fix(acp): order command updates after session responses (#6803)
Problem: ACP clients can discard available_commands_update when it arrives before a session-opening response, leaving slash commands unavailable after new, load, or resume.

Root cause: Session handlers emitted updates while their JSON-RPC request was still executing. Moving the send to a goroutine did not establish a wire-order guarantee.

Fix: Add a transport-owned post-response hook and route command advertisements for session/new, session/load, and session/resume through it while preserving transcript replay and config-rebuild ordering.

Verification: Focused wire-order tests repeated 10 times, ACP/control tests, ACP race tests, go vet, and the full Go suite passed.

Co-authored-by: RainbowXie <[email protected]>
Co-authored-by: ssfdust <[email protected]>
2026-07-22 14:38:22 +08:00
SivanCola c53c13c178 docs(release): prepare v1.17.18 notes (#6799)
Summary:
Add reviewed bilingual release notes for the 1.17.18 stable release.

Source:
Cover the merged pull requests between v1.17.17 and the 03cc7749 release candidate, including Remote Workbench, Auto Guard, MCP authorization, desktop file preview, updater recovery, and session safety.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.17.18 --output /tmp/reasonix-release-notes-1.17.18.md
- git diff --check
2026-07-22 13:45:12 +08:00
SivanCola 03cc774973 Fix Remote Workbench release blockers / 修复远程工作台发版阻断 (#6790)
* fix(remote): harden workbench restore and detach

Problem:
- Physical Windows-to-Linux acceptance exposed null Git pages, a crash restoring sessions after provider catalog changes, and leaked attach/runtime processes after disconnect.

Root cause:
- Empty pages cloned into nil slices, typed-nil controllers crossed an interface boundary, and runtime children shared the SSH session while proxy teardown waited indefinitely.

Fix:
- Preserve allocated empty result pages, normalize typed-nil controllers, detach and reap runtime children, and make proxy teardown bounded.
- Add regression coverage for clean Git pages, provider removal restore, child reaping, and blocked stdin shutdown.

Verification:
- go test ./... and desktop go test ./...
- go test -race ./... and desktop go test -race ./...
- go vet ./... and desktop go vet ./...
- pnpm frontend test:all and build
- cache guard, protocol generation check, six-platform CLI cross-build, and npm release dry run

* fix(deps): update x/text for GO-2026-5970

Problem:
- The release CI govulncheck gate detected reachable GO-2026-5970 in golang.org/x/text v0.38.0.

Root cause:
- Both the root and Desktop modules pinned the vulnerable x/text release used through proxy normalization.

Fix:
- Upgrade golang.org/x/text to v0.39.0 in both modules and refresh their checksums.

Verification:
- govulncheck ./... reports no reachable vulnerabilities in both modules.
- Root and Desktop full tests, vet, and race suites pass.
2026-07-22 10:58:58 +08:00
SivanCola 5480019c4f feat(agent): evolve Auto mode with Auto Guard / 以 Auto Guard 强化自动模式 (#6732)
* feat(agent): add Auto-mode failure recovery checkpoint / 为自动模式增加失败恢复检查点

Problem:
In Auto mode, after a tool or verification failure the agent could silently
change strategy, expand write scope, or raise risk without a user decision.

Root cause:
There was only the Ask/Auto/YOLO permission axis and plan confirmation. No
host-side checkpoint blocked unconfirmed recovery mutations after failures.

Fix:
- Add a shared per-task recovery state machine (idle/diagnosing/awaiting/
  approved_once) consulted before permission and write-lock acquisition.
- Deterministic rules auto-continue same-strategy verification retries and
  force confirm for high-risk, scope, strategy, or consecutive failures.
- Independent recovery reviewer with isolated policy/session; fail closed.
- Fresh recovery approval cards (continue/revise/stop); never session-drain.
- Config/session defaults: new sessions on, pre-upgrade sessions off.
- Desktop composer toggle + settings default; Bot structured recovery UX;
  content-free recovery metrics; reviewer/gate sidecars for cold restart.
- Docs: English + Chinese tool-approval modes.

Verification:
- go test ./internal/recovery/ ./internal/control/ ./internal/bot/ -run
  'TestRecovery|TestCache|Approval' -count=1
- Cache-shape test: success-path system/tools hashes unchanged with gate on
- desktop package compiles (go test -c)

Cache-impact: low — success path keeps stable system prompt and tool schemas
byte-stable; only failure-path dynamic user-tail and an isolated reviewer
session add tokens.
Cache-guard: internal/recovery TestRecoveryWiringPreservesSuccessPathCacheShape
System-prompt-review: N/A for main agent (reviewer uses a separate fixed policy)

* fix(agent): harden auto recovery checkpoints

Problem:
Auto recovery could start a concurrent Agent.Run, wait forever in headless mode, duplicate revise feedback, and persist incomplete or orphaned recovery state.

Root cause:
Failure guidance was routed through Controller.Steer, headless detection relied on approval timeout, reviewer transcripts were treated as session sidecars, and recovery state lacked ordered atomic persistence and task-scoped replay semantics.

Fix:
Deliver guidance to the exact Agent.Run, use explicit headless mode, isolate root and subagent recovery identities, make recovery state atomic and session-owned, support cold-start decisions, and complete the Wails/mock bridge contract.

Verification:
go test ./...; go test -race ./...; root and desktop vet/lint; desktop tests; frontend typecheck/tests/build; real-browser Continue/Revise/Stop checks.

* style: format recovery checkpoint fields

Problem:
CI stopped before compilation because four recovery-related Go files from the original PR were not gofmt-clean.

Root cause:
Aligned struct fields were added without running the repository-wide formatter gate.

Fix:
Apply gofmt to the affected root and desktop files without changing behavior.

Verification:
The exact root and desktop gofmt checks from CI now produce no output.

* test(recovery): respect Windows file modes

Problem:
The Windows CI leg failed even though the recovery snapshot was written atomically and remained valid JSON.

Root cause:
The test expected POSIX 0600 permission bits from FileMode.Perm on Windows, where NTFS permissions are represented by inherited ACLs and Go reports synthetic 0666 bits.

Fix:
Keep the atomic persistence assertions on every platform and limit the numeric 0600 assertion to platforms where POSIX mode bits are meaningful.

Verification:
Focused recovery tests pass, the Windows test package cross-compiles, and the exact root gofmt gate is clean.

* fix: harden recovery checkpoint session isolation

Problem:
Recovery state could cross session boundaries, delayed snapshots could target a new session, destructive delete tools could bypass human confirmation, synchronous approval replies could be lost, and legacy sessions could resume enabled.

Root cause:
The gate resolved persistence paths asynchronously, session rotations did not consistently replace gate state, approval IDs were bound after event emission, delete tools were absent from deterministic risk rules, and resume ignored branch metadata.

Fix:
Capture and drain persistence per session, reset or carry recovery state explicitly across every rotation, bind approval IDs before emission, classify delete tools as high risk, and restore the persisted per-session preference with a legacy-off default.

Verification:
go test ./...
(cd desktop && go test ./...)
go test -race ./internal/recovery ./internal/control -run 'Recovery|FreshSessionRotations|SetSessionPath'
go vet ./...
./scripts/cache-guard.sh

* fix(recovery): preserve defaults across session resets

Problem:
Recovery settings were omitted from rendered configuration, and fresh sessions could inherit a resumed session preference instead of the configured default.

Root cause:
The serializers did not emit the new fields, while the controller kept one mutable recovery flag for both current-session state and future-session defaults. Desktop metadata snapshots also did not consistently mirror the effective preference.

Fix:
Render and round-trip the recovery settings, separate current and default controller state, route known-new paths through an explicit fresh-session binding, and synchronize Desktop tabs and branch metadata after rotation.

Verification:
go test ./...
(cd desktop && go test .)
go test -race ./internal/control -run recovery rotation cases
(cd desktop && go test -race . -run recovery session cases)

* feat(agent): evolve recovery checkpoint into Auto Guard

Problem:
Auto only applied the recovery checkpoint after failures, duplicated UI and config controls, and escalated every uncertain recovery directly to a human.

Root cause:
High-risk classification was scoped to active failure state, while persisted one-shot grants and recovery-specific Stop and replay branches made the flow stateful and fragmented.

Fix:
Add a deterministic pre-action Auto Guard, return reviewer denials to agents before three-strike human escalation, remove stale grant replay and recovery Stop, consolidate config and UI, and preserve legacy wire and config decoding.

Verification:
- go test ./...
- go test -race ./internal/recovery
- (cd desktop && go test ./...)
- (cd desktop/frontend && pnpm test:typecheck && pnpm test && pnpm build)

* test(recovery): synchronize prompt resolution

Problem:
Linux CI could schedule prompt-resolution goroutines before the gate re-keyed its provisional waiter, leaving two new tests blocked until the package timeout.

Root cause:
The tests resolved real approval IDs without first binding them, so they depended on scheduler timing even though production prompt emitters use BindApprovalID.

Fix:
Bind each approval ID synchronously inside the prompt callback before resolving the decision.

Verification:
- go test ./internal/recovery -run TestHighRiskMutationPromptsBeforeAnyFailure\|TestReviewerBlockReturnsReasonThenEscalates -count=100
- go test -race ./internal/recovery -run TestHighRiskMutationPromptsBeforeAnyFailure\|TestReviewerBlockReturnsReasonThenEscalates -count=100

* ci: restart stuck Windows runner

Problem:
The Windows test job stayed in progress well past its configured 10-minute step timeout and ignored both normal and force-cancel requests.

Root cause:
The hosted runner stopped reporting progress after entering the test step, preventing the workflow from reaching a rerunnable conclusion.

Fix:
Advance the PR head without file changes so the workflow concurrency policy replaces the stuck run with a fresh runner.

Verification:
The same head already passed Ubuntu, macOS, race, lint, coverage, CodeQL, cache-impact, and desktop Linux checks.

* fix(desktop): keep Yolo visible in composer

Problem:
The Auto Guard refinement hid Yolo from the composer unless it was already active, which made a supported approval mode less discoverable and left the three-column control partially empty.

Root cause:
The composer rendered the Yolo button conditionally and narrowed the approval control for a two-option default surface.

Fix:
Always render Ask, Auto, and Yolo, restore the three-option width, and align English/Chinese documentation and regression coverage with the direct selection flow.

Verification:
- pnpm exec tsx src/__tests__/composer-run-strip.test.tsx
- pnpm test:typecheck
- pnpm check:css
- pnpm test
- pnpm build
- Browser DOM/click verification for all three visible options

* fix(recovery): close Auto Guard evidence gaps

Problem:
Auto Guard could miss dependency/config deletes, moves, and common install or publish commands, while the recovery reviewer never received the read-only evidence gathered after a failure.

Root cause:
High-risk classification covered only a subset of file mutation tools and broad command names, and successful diagnostic observations were discarded before populating the task-local diagnosis state.

Fix:
Classify every built-in file mutation path and risky package-manager subcommand, keep ordinary verification commands on the fast path, and persist bounded, deduplicated, task-local diagnostic excerpts for the isolated reviewer. Mark dynamic reviewer evidence as untrusted.

Verification:
- go test -race ./internal/recovery -count=1
- env -u DEEPSEEK_API_KEY go test ./... -count=1
- go vet ./...
- cd desktop && go test ./... -count=1
- go test ./internal/recovery -run TestRecoveryWiringPreservesSuccessPathCacheShape -count=1
- ./scripts/cache-guard.sh

* fix(recovery): remove obsolete path helper

Problem:
The PR lint job rejected an unused pathFromArgs helper left behind after high-risk classification moved to multi-path extraction.

Root cause:
The compatibility wrapper had no remaining callers, while go vet does not report unused package-level functions.

Fix:
Remove the obsolete wrapper and retain pathsFromArgs as the single path extraction implementation.

Verification:
- go test ./internal/recovery -count=1
- go test -race ./internal/recovery -count=1
- go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m

* test(recovery): freeze Auto Guard behavior matrix

Problem:
Auto Guard routing is embedded in the Gate coordinator, so later
refactors risk changing product semantics without a focused oracle.

Fix:
Add a pure Decision engine skeleton and table-driven golden coverage for
the product matrix: ordinary Auto paths never call the reviewer, high
risk and scope/strategy/second-failure ask immediately, ambiguous
recovery reviews once, reject budget escalates on the third block, and
reviewer errors fail closed without burning reject credits.

Verification:
go test ./internal/recovery/

* refactor(recovery): pure decision core, collapsed state, provider reviewer

Problem:
Auto Guard mixed classification, routing, agent-based review, and multi-source
runtime state in one Gate, making later product simplification risky.

Root cause:
Phase/ConsecutiveFails/SafeRetryLeft/Pending lived as parallel truth, and the
reviewer spun a full agent.Agent with tools and compaction.

Fix:
- Route through a pure Decide engine (Classifier facts → allow/review/ask).
- Keep one taskRuntime + activeFailure; derive Phase and wire compatibility
  fields on Snapshot; Restore still drops Pending/ApprovalID grants.
- Replace the agent reviewer with a bounded provider.Stream call
  (temp 0, max 256 tokens, 8 KiB total, 4 KiB output cap, recovery-reviewer
  usage source).

Verification:
go test ./internal/recovery/ ./internal/control/ -run 'Recovery|AutoGuard|recovery'

* refactor(desktop): one-click Auto recovery and remove Guard toggles

Problem:
Recovery cards still used select-then-confirm, and unreleased per-tab /
Settings Auto Guard toggles competed with Ask/Auto/YOLO as a fourth control.

Fix:
- Recovery card is one click: Continue this step / Try another approach,
  optional guidance, details collapsed, localized reason by change_kind.
- Empty revise feedback is accepted; host injects a fixed agent guidance.
- Remove desktop Settings and Wails per-tab Auto Guard toggles; kill switch
  remains [agent].auto_recovery_checkpoint only.
- Stop persisting RecoveryCheckpointEnabled on BranchMeta.
- Document guarded Auto semantics in EN/ZH mode docs.

Verification:
go test ./internal/recovery ./internal/control -run Recovery
desktop go test focused recovery/session cases
pnpm exec tsx src/__tests__/decision-surface.test.tsx

* fix(desktop): keep legacy Auto Guard Wails no-ops

Problem:
Removing the Wails methods broke frontend typecheck against generated
bindings that still list the old surfaces.

Fix:
Restore thin no-op / config-read Wails methods so older frontends and
generated bindings keep compiling, without reviving per-tab or Settings
Auto Guard toggles as product controls.

Verification:
pnpm typecheck
go test . -run AutoRecovery|RecoveryCheckpoint|NewSessionUsesFresh (desktop)

* fix(recovery): unblock legacy Approve, honor project kill switch, keep evidence JSON valid

Problem:
1. Pre-action high-risk recovery cards parked a live waiter without taskRuntime,
   so Approve looked them up via Snapshot and never called ResolveRecovery —
   old clients could hang until context cancel.
2. Desktop re-applied Auto Guard from user-only config after boot, ignoring
   project [agent].auto_recovery_checkpoint = "off".
3. Reviewer evidence over 6 KiB was clipped mid-JSON, producing invalid payload.

Root cause:
Live waiter state was inferred from a persistence-shaped snapshot; desktop
overwrote the controller kill switch; field budgeting happened after marshal.

Fix:
- Gate.HasApproval(id) inspects the live waiter table; Approve routes through it.
- Remove desktop secondary SetRecoveryCheckpointEnabled / DefaultEnabled overrides.
- Budget optional evidence fields, then marshal; assert json.Valid and size.
- UTF-8-safe feedback clip at the 4 KiB host cap.

Verification:
go test -race ./internal/recovery ./internal/control
go test . -run AutoRecovery|NewSessionUsesFresh (desktop)

* fix(ci): clear Auto Guard lint failures and freeze project kill switch

Problem:
CI golangci-lint v2.12.2 failed on three issues introduced by the Auto Guard
refactor: an empty if (SA9003), a redundant explicit type (ST1023), and an
unused desktop helper (U1000).

Fix:
- Assert the real recovery approval id is cleared after legacy Approve.
- Use r := reviewer in the decision matrix helper.
- Remove unused currentTabRecoveryCheckpointEnabled.
- Add boot regression: global on + project off disables Auto Guard at Build
  and after SetFreshSessionPath.

Verification:
go test ./internal/recovery ./internal/control ./internal/boot -run LegacyApprove|BehaviorMatrix|BuildHonorsProject|HasApproval
staticcheck -checks SA9003,ST1023,U1000 on touched packages

* test(desktop): freeze project Auto Guard kill switch

Problem:
The existing regression only exercised boot.Build and did not pass through the desktop runtime wiring that previously reapplied the user-global Auto Guard setting.

Root cause:
The test used a legacy-compatible config path and treated a same-controller session rotation as proof of desktop rebuild behavior, so the secondary desktop overrides were outside its coverage.

Fix:
Use the primary user config path and add a desktop integration regression that builds a project tab with global on/project off, then verifies initial configuration and a fresh-session rotation both remain disabled.

Verification:
go test ./...
go test -race ./internal/recovery ./internal/control ./internal/boot
(cd desktop && go test ./...)
golangci-lint v2.12.2 in root and desktop
./scripts/cache-guard.sh

* refactor(recovery): make Auto Guard intrinsic to Auto

Problem:
The short-lived auto_recovery_checkpoint kill switch duplicated Auto mode state across config, recovery decisions, controller sessions, and desktop tabs, increasing user and maintenance cost.

Root cause:
Auto Guard was introduced as an optional feature even though its decision matrix is part of Auto mode's product contract.

Fix:
Remove the config key and the full runtime enablement chain. Ignore retired TOML and session metadata fields, clean them on the next save, and retain only no-op Wails compatibility methods for older generated frontends. Update docs and regressions, and format files that blocked the prior CI run.

Verification:
- go test -race ./internal/recovery ./internal/control
- go test ./internal/agent ./internal/config ./internal/boot
- cd desktop && go test ./...
- cd desktop/frontend && pnpm typecheck && pnpm test:typecheck
- decision-surface and composer-run-strip tests
- ./scripts/cache-guard.sh

* test(boot): isolate Windows config directories

Problem:
The Windows root-module CI repeatedly loaded a test provider from an earlier case, retried example.invalid for 92 seconds, and then timed out.

Root cause:
isolateConfigHome redirected HOME and XDG_CONFIG_HOME but not the AppData and LocalAppData directories used by os.UserConfigDir and os.UserCacheDir on Windows.

Fix:
Redirect USERPROFILE, AppData, and LocalAppData into the same per-test temporary directory.

Verification:
- go test ./internal/boot
- repeated the affected Boot tests three times
- confirmed the Go standard library reads AppData and LocalAppData on Windows

* fix(desktop): prevent approval control overlap

Problem:
The three-option approval bar overlapped the adjacent model selector at compact composer widths.

Root cause:
The 760px container rule shrank the approval wrapper to 164-178px while its child mode bar retained a fixed 200-232px width.

Fix:
Allow the approval mode bar to shrink with its compact parent and add a CSS contract regression for the responsive geometry.

Verification:
- pnpm test
- pnpm build
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
- ./scripts/cache-guard.sh
- in-app browser geometry at 1280px and 1182px viewport widths

* fix(recovery): close Auto Guard review gaps

Problem:
Auto Guard missed wrapped dependency and configuration mutations, while desktop telemetry replayed cumulative recovery counters on every completed turn.

Root cause:
The high-risk classifier recognized only narrow command and path shapes, and the desktop sampled controller totals rather than per-turn deltas.

Fix:
Harden wrapper and configuration mutation classification, add atomic recovery metric draining, and cover both regressions with focused tests.

Verification:
- go test ./...
- go test -race ./internal/recovery ./internal/control
- cd desktop && go test ./...
- go vet ./...
- ./scripts/cache-guard.sh

* refactor(recovery): make Auto interruption-light

Problem:
Auto asked users to understand recovery internals and interrupted reversible workspace work, while Plan required select-then-confirm.

Root cause:
The recovery classifier treated config and dependency edits, strategy changes, and the second failure as human boundaries, and the desktop surface exposed internal guard concepts.

Fix:
- reserve human prompts for deterministic hard boundaries or three failed attempts
- keep low-risk recovery moving when optional review is unavailable
- simplify Plan and recovery decisions to two direct, accessible buttons
- align desktop, bot, docs, and localized copy

Verification:
- go test ./...
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend build
- browser verification for Auto, Plan + Auto, Goal + Auto, Plan confirmation, and recovery confirmation

* fix(recovery): align Auto boundaries and reviewer

Problem:
Auto could silently allow destructive or external commands, while the isolated reviewer still rejected bounded project-local dependency, config, strategy, and scope changes after failures.

Root cause:
The deterministic shell classifier omitted destructive Git variants, global package-manager forms, and external mutation CLIs. The fixed reviewer contract only allowed same_strategy verdicts after Auto policy moved bounded workspace recovery to the automatic path.

Fix:
- Classify destructive Git state changes, global package operations, HTTP/GitHub API mutations, and remote command families as hard boundaries while preserving proven read-only fast paths.
- Allow reviewer continue verdicts for bounded, task-aligned strategy and scope changes while keeping risk and uncertainty fail closed.
- Align the three-failure rationale and add policy and classifier regression coverage.

Verification:
- go test ./... -count=1
- go test -race ./internal/recovery ./internal/control ./internal/agent -count=1
- go vet ./...
- golangci-lint v2.12.2 ./internal/recovery/... (0 issues)
- ./scripts/cache-guard.sh

* fix(auto): scope reusable recovery approvals safely

Problem:
Auto Guard repeatedly interrupted bounded external operations, while several unlisted shell, PowerShell, Git checkout, and attached GitHub CLI flag forms could bypass the intended hard boundary.

Root cause:
Recovery confirmations were one-shot only and the shell classifier relied on an incomplete deny list without a host-owned semantic authorization scope.

Fix:
Add task-scoped semantic grants for explicitly bounded operations, expose opt-in actions across Desktop, CLI, and Bot, fail closed for unknown mutations, and cover destructive sibling forms and lifecycle boundaries.

Verification:
go test ./...
go test -race ./internal/recovery ./internal/control ./internal/agent
pnpm test:typecheck && pnpm test
pnpm run build
./scripts/cache-guard.sh

* refactor(auto): simplify recovery confirmation UI

Problem:
- Auto Guard confirmations exposed recovery diagnostics at equal visual priority, making simple decisions noisy.

Root cause:
- Raw recovery fields reused warning cards and appeared before the user's actual choices.

Fix:
- Show one concise action summary, recommend the safer alternative first, move optional task grants after the decisions, and flatten technical diagnostics behind disclosure.

Verification:
- pnpm test
- pnpm typecheck
- pnpm check:css
- In-app browser checks for default, expanded, and 620px layouts with no console errors.

* feat(auto): add optional recovery guidance

Problem: Auto Guard offered only a generic retry or one-shot continue, so users could not steer a safer alternative without adding another heavyweight decision.

Root cause: the simplified recovery surface removed free-text feedback entirely instead of keeping it behind progressive disclosure.

Fix: add a quiet guidance entry that expands an inline editor, routes text only through revise, clears unrelated task grants, restores keyboard focus, and localizes the flow.

Verification: pnpm test; pnpm typecheck; pnpm check:css; interactive in-app browser review of collapsed and expanded states.

* fix(auto): bind task grants to exact targets

Problem:
Task-scoped recovery grants for git pushes matched every ref on the same remote, stale grants remained attached to reused task IDs, and Desktop described a checked task grant as a one-shot continue.

Root cause:
The semantic grant key omitted the destination ref, runtime state did not retire grants when the host task scope changed, and recovery approval events exposed only a boolean instead of the host-classified scope.

Fix:
Scope reusable pushes to an explicit remote and destination, reject ambiguous or behavior-changing variants, retire grants on task-scope changes, expose a transient display scope to every frontend, and group explicit consent with the Continue action.

Verification:
go test ./...
go vet ./...
go test -race ./internal/recovery ./internal/eventwire ./internal/control ./internal/agent -count=1
pnpm test:all && pnpm build
(cd desktop && go test ./... -count=1)
./scripts/cache-guard.sh

* test(auto): cover legacy task-grant events

Problem:
Older backends do not send the optional task_grant_scope field.

Root cause:
The new exact-scope rendering tests only exercised events produced by the updated backend.

Fix:
Keep one grantable recovery fixture on the legacy wire shape and assert that Desktop renders the generic bounded-scope explanation.

Verification:
pnpm test:typecheck
pnpm exec tsx src/__tests__/decision-surface.test.tsx

* fix(auto): drain recovery writes before new session

Problem:
The macOS PR job intermittently failed while TempDir removed a session directory because /new could return with an old-session recovery snapshot still writing asynchronously.

Root cause:
ClearSession drained path-bound recovery persistence before deleting artifacts, but NewSession rotated the session path without the equivalent lifecycle barrier.

Fix:
Drain pending recovery writes for the old path before the final session snapshot and path rotation. Add a deterministic blocking persistence regression test that proves NewSession waits for the drain.

Verification:
- go test ./... -count=1
- go test -race ./internal/control -run '^(TestFreshSessionRotationsClearRecoveryState|TestNewSessionWaitsForPendingRecoveryPersistence)$' -count=10
- go test ./internal/control -run '^(TestFreshSessionRotationsClearRecoveryState|TestNewSessionWaitsForPendingRecoveryPersistence)$' -count=50
- go vet ./internal/control ./internal/recovery
- (cd desktop && go test ./... -count=1)
- ./scripts/cache-guard.sh
2026-07-22 02:52:24 +08:00
SivanColaandtaibai233 93c82a948a Remote Workbench with Local Provider Broker / 使用本机模型的远程工作台 (#6728)
* feat(remote): add rpcwire, RuntimeAPI registry, and protocol codegen

Problem:
Remote Workbench needs a bidirectional JSON-RPC transport and a single Go
registry that generates schema, Schema Hash, and TypeScript DTOs so Desktop and
Host negotiate strictly by schema, not by full Build ID hard-fail.

Root cause:
main-v2 lacked rpcwire and a generative Remote protocol surface.

Fix:
- Port transport-neutral NDJSON JSON-RPC (rpcwire) with strict frames, concurrent
  requests, cancel, and size limits (adapted from #6725).
- Port RuntimeAPI protocol registry + schema generator; regenerate committed
  artifacts for main-v2 eventwire.
- Mark eventwire large fields externalizable for content-ref offload without
  changing provider-visible semantics.
- Extend directions for Provider Broker (host_to_client_request /
  client_to_host_notification) and register broker methods in the frozen surface.

Verification:
- go test ./internal/rpcwire ./internal/remote/protocol ./internal/remote/protocolgen ./internal/eventwire
- go run ./cmd/remote-protocol-gen -check -root .

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

* feat(remote): add Desktop Provider Broker and TargetManager

Problem:
Workbench Hosts must consume models without holding API keys, and the main
window needs Local + one Remote adapters with a single active projection.

Root cause:
No in-process Broker over rpcwire and no target fencing manager on main-v2.

Fix:
- Desktop Broker endpoint: catalog, stream open/cancel, chunk/end notifications,
  concurrency limits, DeepSeek ToolCallReasoning catalog flags, connection-wide
  stream cancel on close (direction from #6722; fencing ideas from #6725).
- TargetManager: permanent Local, optional Remote, last-click-wins fencing,
  busy remote host-swap rejection, restart reconnect hint only (no auto SSH).

Verification:
- go test ./internal/remote/broker ./internal/remote/workbench/target

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

* feat(remote): workbench SSH, attach-workspace, adapters, mirror, trust

Problem:
Remote Workbench needed end-to-end transport, lifecycle, main-window adapters,
workspace-safe files, mirrors, and Provider trust — not Serve HTML or HTTP
Gateway tunnels.

Root cause:
Prior foundation only had protocol/Broker/TargetManager; no SSH stdio path,
attach proxy, runtime grace, or UI wiring.

Fix:
- Windows: system OpenSSH + AskPass + Job Object (adapted from #6725); remote
  command is `reasonix remote attach-workspace --stdio`.
- macOS/Linux: Go SSH session stdio to the same attach entry.
- attach-workspace + per-workspace runtime on Unix socket; 5-minute grace after
  detach; Schema Hash gate on initialize.
- Desktop Workbench adapters (connect/disconnect/switch Local, remote RPC,
  Provider trust prompt); OpenRemoteWorkspace uses workbench path.
- File containment (relative refs, no symlink leaves, atomic writes).
- Local mirror store with digest validation; restore requires session.jsonl.
- Durable Provider trust store (host+fingerprint → allowed refs only).
- Frontend workbenchTarget helpers + tests.

Verification:
- go test ./internal/remote/workbench/... ./internal/remote/broker ./internal/rpcwire ./internal/remote/protocol ./internal/cli
- cd desktop && go test -run 'TestRemote|TestWorkbench|TestAskPass|TestOpenRemote'
- pnpm exec tsx --test desktop/frontend/src/__tests__/workbench-target.test.ts

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

* docs(remote): record source PR authors as repository co-contributors

Problem:
Repository co-contribution attribution requires explicit documentation and
commit-level Co-authored-by trailers for the source PR authors (#6722, #6725).

Root cause:
PR body credits alone do not enter the GitHub contribution graph.

Fix:
Document both source PR authors, what was adopted, and that GitHub co-author
attribution uses Co-authored-by trailers with public noreply emails in
REMOTE_WORKBENCH docs (EN + zh-CN).

Verification:
- docs/REMOTE_WORKBENCH.md contains Repository co-contributors table
- every commit on this branch includes both Co-authored-by trailers

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

* fix(remote): close the workbench broker runtime loop

Problem:
Remote Workbench still mixed Remote state with the Local controller, left parts of the runtime API empty, and could lose reconnect, operation, history, Git, and provider semantics in real main-window use.

Root cause:
The first integration established the transport and broker direction but did not fully fence target projections, rebuild controller profiles, persist remote execution state, or cover strict protocol and process lifecycle parity.

Fix:
- route the existing main window through the Host-owned controller, files, Git, history, catalogs, operations, goals, checkpoints, and slash completion
- keep provider credentials on Desktop while forwarding strict provider requests, effort, DeepSeek tool-call reasoning, and reconnect-safe stream generations
- harden Build ID/schema negotiation, SSH/AskPass process cleanup, runtime socket ownership, workspace containment, mirror pulls, disconnect fencing, and background target isolation
- add generated protocol contracts and regression coverage for no-Host-key broker use, paging, reconnect, overflow, lifecycle, and projection behavior

Verification:
- env -u DEEPSEEK_API_KEY REASONIX_RELEASE_CACHE_GUARD=1 go test -race ./...
- cd desktop && go test ./... && go vet ./... && go build ./...
- pnpm --dir desktop/frontend test:all && pnpm --dir desktop/frontend build
- go run ./cmd/remote-protocol-gen -check -root .
- Windows amd64 cross-compilation for root Remote packages and desktop module
- scripts/check-cache-impact.sh

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

* fix(remote): bind attach to its workspace target

Problem:\nCI lint found an overwritten normalized workspace, and initialize could replace the SSH attach target before runtime startup.\n\nRoot cause:\nThe attach bootstrap normalized Options.Workspace but then assigned the client-provided initialize workspace to the same variable without comparing the two.\n\nFix:\nKeep the normalized attach target, normalize the requested workspace separately, and reject mismatches before schema negotiation or runtime startup.\n\nVerification:\n- go test ./internal/remote/workbench/attach ./internal/remote/workbench/runtime\n- go vet ./internal/remote/workbench/attach\n- golangci-lint v2.12.2: 0 issues

* fix(desktop): remove the legacy remote child window

Problem:\nThe same-window Remote Workbench still carried an unreachable child-window launcher and special shell lifecycle, causing Desktop CI unused-code failures and contradicting the selected product model.\n\nRoot cause:\nThe App bindings were migrated to TargetManager, but the old ticket-based Serve HTML launch path and its window-specific startup branches were left behind.\n\nFix:\nRemove remote-window tickets, spawning, middleware, lifecycle branches, and obsolete helpers so every SSH workbench runs in the existing main window.\n\nVerification:\n- cd desktop && go test ./...\n- desktop and root golangci-lint v2.12.2: 0 issues\n- Windows amd64 Desktop cross-compilation\n- wails generate module

* fix(remote): preserve workspace diff parity

Problem: After rebasing onto #6733, WorkspaceChangeDetail still executed against the Desktop filesystem while a Remote target was active.

Root cause: The frozen RuntimeAPI had no method for the newly added per-file workspace diff query.

Fix: Add a strict workspace/changeDetail contract, route Desktop through the Host runtime, and generate bounded Git/checkpoint patches inside the selected remote workspace.

Verification: go test ./...; go test -race ./...; cd desktop && go test ./...; pnpm test:all; protocol codegen check; vet/build/lint; Windows cross-compile.

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

* Harden Remote provider broker lifecycle

Problem: the local Provider Broker became active before the live SSH peer was rebound to its trusted fingerprint, and slow provider consumers could block detach while the Host mutex was held.

Root cause: peer validation happened after client.Connect returned, while Host stream delivery performed blocking channel sends inside the global lock.

Fix: gate Broker activation on the authenticated live peer identity and move ordered Host delivery into bounded per-stream queues with a sole output pump.

Verification: go test -race ./internal/remote/broker; go test ./internal/remote/workbench/client; cd desktop && go test -race . -run 'Test(AuthorizeWorkbenchPeerFailsClosed|Workbench|Remote)' -count=1.

* Restore Remote sessions across runtime restarts

Problem: Remote sessions existed only in the runtime process map, so a normal detach followed by runtime exit made persisted transcripts invisible on reconnect.

Root cause: the Host had no durable mapping between protocol session identities, controller profiles, topics, and transcript paths.

Fix: add a per-workspace atomic session registry, lazily rebuild controllers after Broker activation, retain temporarily unavailable records, and persist lifecycle/profile changes without holding the runtime mutex across file I/O.

Verification: go test -race ./internal/remote/workbench/runtime -count=1; go test -race ./internal/remote/workbench/runtime -run 'TestRuntime(RestoresSessionRegistryAfterProcessRestart|EarlyShutdownDoesNotOverwriteUnreadRegistry)' -count=20.

* Keep Remote projections and provider state current

Problem:
Remote callbacks stayed bound to a stale local tab, provider metadata and authorization were captured at connect time, and the bounded-handler regression test released its blockers before overload dispatch was observable.

Root cause:
Local navigation was not serialized with Remote activation, Broker descriptors omitted compaction/pricing fields, provider closures captured immutable config, and the rpcwire test synchronized only handler startup.

Fix:
Fence visible local navigation and rebind callbacks on Remote activation, propagate complete non-secret provider metadata, refresh config and trust-scoped access for live Broker connections, fail closed on empty authorization, and wait for all overload responses before releasing handlers.

Verification:
go test -race ./internal/boot ./internal/remote/protocol ./internal/remote/broker ./internal/remote/workbench/client ./internal/remote/workbench/runtime ./internal/rpcwire
(cd desktop && go test -race ./...)
go test -race ./internal/rpcwire -run TestInboundHandlerConcurrencyIsBoundedWithoutBlockingResponses -count=100
go run ./cmd/remote-protocol-gen -check -root .
./scripts/cache-guard.sh

* Constrain restored Remote transcript paths

Problem:
An edited session registry could name an opaque topic that the wire contract permits but restoration rejected, while a nested transcript path could traverse a directory symlink outside SessionDir.

Root cause:
Registry validation derived a topic_ prefix from an opaque protocol ID and only performed lexical containment plus a final-component symlink check.

Fix:
Accept any non-empty opaque topic ID, require runtime transcripts to be direct children of SessionDir, close partially built controllers on restore errors, and isolate fallback socket paths per test run.

Verification:
go test -race ./internal/remote/workbench/runtime -count=3

* Fence late Remote projection callbacks

Problem:
A callback or snapshot refresh captured for one local tab could complete after Remote was rebound to another tab and still emit into the stale surface.

Root cause:
Reactivation marked Remote visible before rebinding callbacks, and callback/refresh completion trusted its captured tab ID instead of the current projection binding.

Fix:
Bind callbacks before activation, route late events through the current Remote tab under the transition fence, redirect snapshot completion to the live binding, and drop completion while Local is active.

Verification:
cd desktop && go test -race -run 'TestWorkbenchLateCallbackUsesCurrentProjectionTab|TestSetActiveTabSwitchesRemoteProjectionOnlyForDifferentTab|TestConcurrentActivateTopicSerializesSingleSurfacePruning' -count=20

* Synchronize Remote host-key prompt regression

Problem: The full desktop race suite detected an unsynchronized map write in the host-key prompt serialization test.

Root cause: The test started the first prompt goroutine before registering the second managed host, allowing the goroutine to read the manager map while the test wrote it.

Fix: Register every host before launching concurrent prompts and wait for both prompt goroutines during test cleanup.

Verification: cd desktop && go test -race -run '^TestHostKeyPromptsAreSerializedForGlobalDialog$' -count=100 .; cd desktop && go test -race ./...

* Wait for queued background task teardown

Problem: Windows CI could finish TestBackgroundTaskReturnsBeforeSlotFrees while its background job was still persisting transcript artifacts, causing TempDir cleanup to fail with a non-empty directory.

Root cause: The test waited only for provider startup and then slept for 50 milliseconds instead of synchronizing with the job's terminal lifecycle.

Fix: Scope the job to the test session, capture its returned job ID, wait for jobs.Done, and close the manager on every exit path before temporary-directory cleanup.

Verification: go test -race ./internal/agent -run '^TestBackgroundTaskReturnsBeforeSlotFrees$' -count=100; REASONIX_RELEASE_CACHE_GUARD=1 go test -race ./internal/agent; REASONIX_RELEASE_CACHE_GUARD=1 go test ./...; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./internal/agent; go vet ./internal/agent; golangci-lint run ./internal/agent/...

* fix(remote): fall back to server root workspace

* fix(remote): close review blockers for workbench v1

Problem: Remote Workbench could silently expose the Host root, strand a session after registry persistence failure, leave local provider streams running after cancellation, and fail the Ubuntu/Windows attach tests.

Root cause: One-click workspace resolution used an implicit root fallback; profile changes closed or mutated live controllers before durable state succeeded; Broker delivery lacked an abort path and open cancellation did not reach Desktop; attach readiness and tests depended on connection and timing assumptions that were not portable.

Fix: Require explicit workspace selection, roll profile changes back while preserving the old controller, propagate best-effort Broker cancellation with abortable delivery, remove the readiness probe generation race, and use deterministic platform-aware attach tests.

Verification: Remote, Broker, attach, runtime, rpcwire, race, Desktop, frontend Remote, TypeScript, production build, protocol generation, cache guard, go vet, and repeated cancellation/attach regressions passed.

* fix(remote): make attach startup portable

Problem:
The Windows CI attach integration test could not observe the in-process AF_UNIX listener and reported that the runtime never became ready.

Root cause:
Startup readiness was inferred with os.Stat on the socket path, but Windows AF_UNIX does not expose a portable socket file. A separate readiness connection is also unsafe because runtime connections own generations.

Fix:
Let the real attach connection retry bounded socket dials until the runtime listener accepts it, and return immediately after starting the in-process test runtime.

Verification:
- attach integration test repeated 50 times
- attach race test repeated 30 times
- Windows amd64 test binary cross-compiled
- go test ./internal/remote/...
- remote broker, attach, and runtime race tests

* fix(remote): keep runtime sockets portable

Problem:
Windows CI still could not connect to the in-process workbench runtime even with bounded dial retries.

Root cause:
The generated AF_UNIX address exceeded the platform path limit when the remote home itself was long, as on Windows hosted runners.

Fix:
Compact the runtime socket hierarchy while keeping it scoped under the Reasonix home, and add a regression test against the portable AF_UNIX length bound.

Verification:
- attach integration test repeated 50 times
- runtime socket path tests repeated 20 times
- attach and runtime race tests
- Windows amd64 runtime test binary cross-compiled
- go test ./internal/remote/...
- protocol generation check and cache guard

* fix(desktop): preserve native macOS app ownership

Problem:
Packaged macOS builds used reasonix-guard as CFBundleExecutable, so the Dock and LaunchServices owned a non-UI parent while the Wails child owned the window. The app could stay running without reopening its window from the Dock.

Root cause:
The packaging step replaced Wails' native bundle entry point to route startup through Guard. macOS application identity cannot be transferred reliably across that parent-child process boundary.

Fix:
Keep reasonix-desktop as the macOS bundle executable, retain Guard and the CLI as recovery sidecars, and run the equivalent failed-install, crash-loop rollback, repair, and Safe Mode preflight in Desktop before Wails creates WebView. Update packaging contracts and recovery documentation for the platform-specific launch model.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- go test ./... (desktop module)
- go test -race -run 'TestDesktopStartupRecovery|TestDesktopPackagesPreserveNativePlatformLaunchers' -count=1 . (desktop module)
- go vet ./... (desktop module)
- DESKTOP_BUILD_SKIP_DMG=1 scripts/desktop-build.sh darwin/arm64 v0.0.0
- Signed packaged App launched with reasonix-desktop as PID 1 child, one Desktop process, no Guard process, and an onscreen CoreGraphics window

* fix(desktop): satisfy startup recovery lint

Problem:
The Desktop CI job rejected the macOS startup recovery helper because continueLaunch was initialized before every return path overwrote it.

Root cause:
The named return value carried an unnecessary default assignment that go vet did not flag but golangci-lint's ineffassign check did.

Fix:
Initialize only the Safe Mode result and let each branch set the continue decision explicitly.

Verification:
- go test -run 'TestDesktopStartupRecovery|TestDesktopPackagesPreserveNativePlatformLaunchers' -count=1 .
- go vet ./...

* fix(remote): close workbench runtime safety gaps

Problem:
Remote Workbench could reject typed client requests before session hydration, execute duplicate mutations more than once, lose the committed target during a failed replacement, race controller lifecycle changes, and expose Local saved-session bindings while Remote was active.

Root cause:
Client authority hydration preserved serialized zero values, the advertised requestId contract had no runtime ledger or request sequencer, TargetManager stored candidates as committed state, and unsupported Remote history actions silently fell through to Local bindings.

Fix:
Authoritatively hydrate client-owned fields while preserving retry IDs, serialize runtime requests with a bounded 24-hour mutation replay ledger, fence controller profile transitions, promote replacement targets only after successful connection, and fail closed for unsupported Remote saved-session operations.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- env -u DEEPSEEK_API_KEY REASONIX_RELEASE_CACHE_GUARD=1 go test -race ./...
- cd desktop && go test ./... && go vet ./... && go build ./...
- pnpm --dir desktop/frontend test:remote
- go run ./cmd/remote-protocol-gen -check -root .
- scripts/check-cache-impact.sh with the live PR body

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

* fix(remote): keep session rotations authoritative

Problem: Completed turns could retain stale approval or ask prompts, while committed new/clear session rotations could return an error after a registry write failure and leave clients on the old runtime epoch.

Root cause: Turn completion only cleared replay events when no prompt remained, session rotation treated the fallible registry write as part of the already-committed controller mutation, and the client did not apply new/clear result epochs.

Fix: Clear terminal prompt state, return and adopt the committed rotation epoch while logging registry degradation, reset stale runtime state, and invalidate the previous snapshot.

Verification: go test ./...; go test -race ./internal/remote/workbench/runtime ./internal/remote/workbench/client; cd desktop && go test ./...; go vet ./...; go run ./cmd/remote-protocol-gen -check -root .

* fix(remote): fence snapshot refreshes and fork cleanup

Problem:
Remote rewind could report failure after destructive state changes committed, late subscribe responses could restore stale runtime authority, and failed forks left transcript artifacts on disk.

Root cause:
Post-commit registry errors were treated as rollbackable, subscribe responses had no request-authority fence, and fork rollback removed only the in-memory child session.

Fix:
Keep committed rewind results authoritative, serialize and revision-fence subscription refreshes with automatic stale retry, validate projection freshness, and remove all artifacts for uncommitted forks.

Verification:
- go test ./...
- go test -race ./internal/remote/workbench/client ./internal/remote/workbench/runtime ./internal/control
- go vet ./...
- cd desktop && go test ./...
- go run ./cmd/remote-protocol-gen -check

* fix(remote): serialize projections and capability boundaries

Problem: Remote subscribe snapshots could overwrite newer events, unsupported Remote Memory surfaces could reach Local state, and the broker test assumed concurrent notification arrival order.

Root cause: Snapshot publication, event callbacks, and authority-changing results had no shared projection boundary; Desktop bindings did not fail closed for frozen capabilities; the test ignored stream sequence numbers.

Fix: Linearize subscribe commits with session delivery and mutation results, install the initial Remote projection within that boundary, reject unsupported Remote Memory and Auto Research access, and assert broker chunks by sequence.

Verification: go test ./...; go test -race ./internal/remote/...; desktop go test ./...; desktop go vet ./...; broker round-trip with coverage at count 500.

* fix(remote): preserve active workbench transport

Problem:
Uninitialized socket probes could evict the live Desktop connection, target transition events exposed candidates as active, and Remote notification overload silently dropped ordered events.

Root cause:
Connection ownership was transferred before initialize response commit, candidate identity and active identity shared one event shape, and rpcwire used best-effort concurrent notification dispatch.

Fix:
Add provisional connection gates with post-write activation and exact daemon build checks; keep transition events on the committed target and fence submits; add ordered bounded notification queues with explicit overflow failure and generation-aware Broker delivery.

Verification:
go test ./...
go test -race ./internal/rpcwire ./internal/remote/broker ./internal/remote/protocol ./internal/remote/workbench/...
(cd desktop && go test ./...)
(cd desktop/frontend && pnpm test:all)

* fix(remote): serialize connection teardown

Problem:
A client closing immediately after initialize could race the response-commit callback, leaving a rejected gate paired with newly activated connection ownership.

Root cause:
Provisional connection teardown and post-write activation checked and mutated the wire registry under separate critical sections.

Fix:
Serialize teardown with connection activation, keep the production notification queue memory bound at 256 entries, and lock the initialize post-write contract with a router regression test.

Verification:
go test ./internal/remote/protocol ./internal/remote/workbench/runtime
go test -race ./internal/remote/workbench/runtime ./internal/remote/protocol ./internal/remote/broker ./internal/rpcwire

* test(remote): cover broker-backed tool loop

Problem:
The no-Host-key integration test only proved a single text response. It did not exercise the critical model-to-Host-tool-to-model loop or DeepSeek reasoning replay across the Desktop Broker.

Root cause:
The stub Provider always returned final text on its first request, so Host tool execution and the second Broker request were outside the end-to-end coverage.

Fix:
Make the stub request read_file first, assert the Host result and reasoning/tool-call replay in the second request, then complete the turn and verify projected tool events.

Verification:
- go test ./internal/remote/workbench/runtime -run '^TestRuntimeControllerUsesDesktopBrokerWithoutHostKey$' -count=1 -v
- go test -race ./internal/remote/workbench/runtime -run '^TestRuntimeControllerUsesDesktopBrokerWithoutHostKey$' -count=20
- go test ./internal/remote/workbench/... ./internal/remote/broker ./internal/rpcwire
- env -u DEEPSEEK_API_KEY go test ./...

* test(remote): add physical Windows Linux acceptance

Context:
The Remote Workbench release gate needs reproducible evidence from a real Windows OpenSSH client to a Linux Host, including Broker-backed tool execution and reconnect cleanup.

Change:
Add an opt-in Windows ARM64-compatible acceptance test that exercises the production SSH transport, Linux workspace queries, Desktop Broker tool replay, session authority after reconnect, and OpenSSH process reaping. The fixture is environment-gated and keeps Provider execution on the Desktop side.

Verification:
- GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go test -c -tags reasonix_remote_integration ./desktop
- go test ./...
- go test -race ./internal/remote/...

* fix(remote): align Desktop and Host build identity

Context:
The Desktop is built from a nested Go module, so Go does not embed vcs.revision there while the bundled Host CLI built from the root module does. Strict Remote Workbench negotiation consequently rejects matching release artifacts as different builds.

Fix:
Allow release builds to link a validated shared source revision into the protocol package. Capture the clean repository revision before packaging mutations and inject it into both the Wails Desktop and bundled CLI sidecar, preserving the dirty suffix for local builds.

Verification:
- go test ./...
- go test ./... (desktop module)
- go test -race ./internal/remote/...
- Windows ARM64 acceptance cross-compile with linked source revision
- bash -n scripts/desktop-build.sh

* fix(remote): preserve direct SSH credentials

Context:
The Windows Workbench direct-host branch reduced a saved Host to username, address, and port before starting OpenSSH. Explicit identity_file and proxy_jump settings were silently dropped, so valid direct connections could fail authentication or bypass the required jump path.

Fix:
Add an injection-safe direct configured transport that preserves identity and ProxyJump as distinct argv values. Route the Windows Workbench direct branch through it while retaining normalized IPv4 and IPv6 target parsing.

Verification:
- go test ./... (desktop module)
- go test -race focused Windows Workbench transport tests
- Windows ARM64 acceptance cross-compile

* fix(remote): share runtime build identity

Context:
The detached workspace runtime duplicated Build ID discovery instead of using the shared protocol implementation. Linked source revisions used by nested Desktop release builds were therefore ignored, causing one installed CLI to reject its own runtime as a different build.

Fix:
Use protocol.CurrentBuildID for production runtime identity while retaining the explicit SourceRevision test seam. This keeps Desktop, attach, and detached runtime negotiation on one validated source of truth.

Verification:
- go test ./internal/remote/workbench/runtime
- go test -race ./internal/remote/workbench/runtime
- linked-revision focused regression test

* fix(remote): allow initial history page

Context:
The Desktop history client intentionally requests the latest page without a cursor, and the Host runtime supports that state. The wire DTO nevertheless marked cursor as required, so strict validation rejected every initial session/history request after a successful Remote turn.

Fix:
Make the history cursor optional, regenerate the frozen protocol artifacts, and cover omission plus strict decoding. Strengthen the physical acceptance fixture to verify both product version and exact source revision before connecting.

Verification:
- go test ./...
- go test ./... (desktop module)
- go run ./cmd/remote-protocol-gen -check -root .
- Windows ARM64 acceptance cross-compile

* fix(remote): validate session mirror snapshots

Context:
After a completed turn the reconnect snapshot includes the persisted session mirror. That complete object incorrectly populated originalBytes while marked non-truncated, violating the frozen externalized-field contract and turning session/subscribe into an internal error.

Fix:
Emit complete session mirrors without truncation-only metadata and cover the artifact contract directly.

Verification:
- go test ./...
- go test -race ./internal/remote/workbench/runtime
- focused session mirror and runtime integration tests

* fix(remote): model session mirror payload

Context:
Remote reconnect snapshots advertised /mirror/session.jsonl as an externalized content pointer, but SessionSnapshot had no schema field at that path. Strict response validation therefore rejected every post-turn reconnect snapshot before Desktop could fetch the mirror.

Fix:
Add the mirror placeholder to the frozen snapshot DTO, replace its concrete body with null during marshaling, and keep the body behind the existing bounded contentRef transfer. Regenerate Go/TypeScript protocol artifacts and prove that no session body is inlined on the wire.

Verification:
- go test ./...
- go test ./... (desktop module)
- go run ./cmd/remote-protocol-gen -check -root .
- Windows ARM64 acceptance cross-compile

* test(remote): add live Desktop Broker acceptance

* test(remote): require live Broker tool loop

---------

Co-authored-by: taibai233 <[email protected]>
2026-07-22 02:29:21 +08:00
SivanCola 7fe7d2b3f3 Simplify MCP authorization for installed servers / 简化已安装 MCP 授权 (#6767)
* fix(mcp): trust installed readers in subagents

Problem:
User-installed MCP tools that declared readOnlyHint could be exposed as readers but then fail before dispatch in planners and read-only subagents. Users were told to configure a second per-tool reader policy after already installing or authorizing the server.

Root cause:
Strict reader trust did not treat explicit install or an exact project launch grant as authority for non-destructive server metadata. Direct, lazy, and on-demand paths also duplicated cached-to-live safety reconciliation, while trusted_read_only_tools remained wired through configuration and Desktop contracts.

Fix:
Authorize non-destructive readOnlyHint tools for installed or launch-authorized servers, carry reader intent only for trusted readers, and share one cached-to-live safety reconciliation boundary across lazy and on-demand adapters. Remove trusted_read_only_tools from runtime, Desktop, examples, and docs while accepting and dropping legacy input on the next save.

Verification:
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && go test -count=1 ./...
- cd desktop/frontend && pnpm test:typecheck
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh
- git diff --check

* refactor(mcp): simplify installed server authorization

Problem:
User-installed MCP servers still depended on official-catalog and per-tool reader trust layers, so direct, lazy, and on-demand paths could disagree and strict child sessions could reject an already authorized reader.

Root cause:
Server authorization, signed package status, cached reader trust, and legacy plan-mode allowlists were modeled as separate overlapping decisions.

Fix:
Remove the official MCP catalog, signature/revocation plumbing, package verification, per-tool cached trust, and obsolete reader settings. Use one server-level authorization source for user installs, host-session installs, and exact project grants while retaining live read-only, destructive, and capability-fingerprint checks.

Verification:
- go test ./internal/plugin ./internal/agent ./internal/boot ./internal/config ./internal/installsource ./internal/pluginpkg ./internal/mcplaunch ./internal/planmode
- go test ./internal/acp ./internal/agent ./internal/plugin ./internal/boot
- cd desktop && go test ./...
- ./scripts/cache-guard.sh
- git diff --check

* refactor(mcp): make install authorization final

Problem:
Installed MCP servers could still encounter server, per-tool, writer, or destructive approval branches, and users had to understand settings that duplicated the install decision.

Root cause:
MCP authorization, cached reader trust, approval policy, and process sandboxing evolved as separate paths across direct, lazy, and on-demand execution.

Fix:
Treat install or exact project identity confirmation as the server authorization boundary, keep only explicit global deny plus Plan/read-only and sandbox safety checks, remove catalog and approval configuration, and unify the persistent MCP process sandbox.

Verification:
env -u DEEPSEEK_API_KEY go test ./...
go build ./...
cd desktop && go test ./...
cd desktop/frontend && pnpm typecheck
cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
cd desktop/frontend && pnpm exec tsx src/__tests__/decision-surface.test.tsx

* chore(mcp): remove retired cache helper

Problem:
CI lint found writeBoolKV left unused after per-tool reader trust was removed.

Root cause:
The cached reader-map fingerprint was deleted, but its serialization helper remained.

Fix:
Remove the dead helper.

Verification:
go test ./internal/plugin
go vet ./internal/plugin
go build ./...

* refactor(mcp): stop blocking schema-only drift

Problem:
Authorized MCP calls could be rejected when a cached tool schema differed from the live server even though authorization and safety classification were unchanged.

Root cause:
A capability fingerprint mixed schema compatibility with read-only and destructive execution safety across direct, lazy, and on-demand paths.

Fix:
Remove the tool-level schema fingerprint chain, keep live server authorization and read-only/destructive reclassification checks, and refresh changed schemas for the next session without interrupting the current authorized call.

Verification:
- go test ./internal/plugin ./internal/agent ./internal/tool
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- go build ./...
- ./scripts/cache-guard.sh
- pnpm --dir site test
- cd desktop && go test ./...

* fix(mcp): preserve short Windows temp paths

Problem:
Windows stdio MCP servers inherited a deeply nested Reasonix private TEMP path. Servers such as MATLAB create watchdog Unix sockets below TEMP and exit before initialization when the path exceeds the platform limit.

Root cause:
MCP private-state preparation redirected TMP, TEMP, and TMPDIR below the workspace-scoped mcp-state directory on every platform even though Reasonix does not currently provide an OS sandbox on Windows.

Fix:
Keep the host temporary-directory environment for Windows stdio MCP processes while retaining private cache and state directories. Preserve the existing private temporary directory behavior on other platforms and add focused regression coverage for both branches.

Verification:
- go test ./internal/plugin -count=1
- GOOS=windows GOARCH=amd64 go test -c ./internal/plugin
- go test ./...
- go vet ./internal/plugin
- git diff --check

* Simplify MCP runtime authorization state

Use Spec.Authorized as the sole resolved server authorization result so installed and host-session MCP servers no longer depend on launch-manager presence.

Keep exact identity grants for project sources, align planner and strict read-only registries with non-destructive readOnlyHint metadata, and document the cooperative trust boundary.

Add source and agent-surface matrix coverage while preserving the main provider-visible MCP tool surface.

* Limit MCP identity digests to project servers

Problem: User-installed and host-session MCP servers still resolved and hashed their executable identity during every startup even though installation was already the authorization decision.

Root cause: Project launch authorization and schema-cache ownership shared generic fingerprint terminology, and start() computed the project identity before checking whether launch approval was required.

Fix: Skip project identity resolution for installed servers, remove dead and non-participating identity state, and rename the remaining concepts to ProjectLaunchIdentityDigest and SchemaCacheKey while preserving existing JSON keys and digest bytes.

Verification: go test ./internal/mcplaunch ./internal/plugin ./internal/capability ./internal/boot ./internal/control ./internal/agent; go test ./...; cd desktop && go test .; go vet ./...; ./scripts/cache-guard.sh; git diff --check.

* Restore MCP 0.53 compatibility surfaces

Problem: MCP authorization was simplified, but the current client still lacked four compatibility surfaces present in the 0.53 line: legacy SSE, workspace roots, progress notifications, and registry browsing/install.

Root cause: the Go transport abstraction dropped inbound notifications and rejected legacy SSE; runtime specs did not carry a separate workspace root; the registry UI and CLI had no official MCP Registry client.

Fix: add inbound request/progress routing across transports, implement legacy SSE with same-origin endpoint protection, advertise roots/list, and add an explicit cached official Registry marketplace to CLI and desktop. Remove stale approval documentation and the dead helper that broke lint.

Verification: go test ./...; focused go test -race for plugin/registry/CLI/control; go vet in root and desktop; desktop go test ./...; frontend production build and marketplace UI test; Windows amd64 cross-build; official Registry browse smoke test.

* Avoid MCP progress capacity overflow

* Align MCP authorization documentation

Problem: CLI help and Chinese documentation still contained ambiguous or obsolete wording that implied installed MCP writer or destructive calls could require per-call approval.

Root cause: The server-level authorization simplification removed MCP approval modes, but not every user-facing explanation was updated consistently.

Fix: State explicitly that install or exact project identity confirmation authorizes the server, that writer and destructive hints do not prompt per call, and that deny, Plan, and strict read-only filtering remain separate boundaries.

Verification: go test ./internal/cli; go vet ./internal/cli; reasonix mcp help smoke test; stale-wording scan; git diff --check.

* Harden MCP registry installs and SSE replies

Problem:
Registry cache freshness was shared across queries, cached entries could flow into install paths, and legacy SSE server requests spawned an unbounded reply goroutine per request.

Root cause:
The cache stored one global timestamp, Resolve reused Search's offline fallback, the desktop installed browse payloads directly, and the SSE reader delegated every reply to a new goroutine.

Fix:
Track freshness per Registry query with legacy-cache compatibility, require live resolution before CLI and desktop installs, and serialize SSE replies through a bounded single-worker queue with finite deadlines.

Verification:
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- env -u DEEPSEEK_API_KEY go test -race -count=1 ./internal/mcpregistry ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -count=1 ./... (desktop)
- env -u DEEPSEEK_API_KEY go vet ./... (root and desktop)
- pnpm run test:all
- pnpm run build
- env -u DEEPSEEK_API_KEY ./scripts/cache-guard.sh
- git diff --check

* Reject unavailable live registry installs

Problem:
A Registry entry could become non-installable between browsing and the desktop's live re-resolution, leaving the generic add flow with an invalid empty connection. The SSE concurrency regression also used a fixed delay after routing the client response.

Root cause:
The desktop resolver verified freshness but not the resolved entry's installability, and the test inferred worker startup from elapsed time.

Fix:
Validate the live entry through PluginEntry before returning it to the installer, and synchronize the SSE test on the first reply POST instead of sleeping.

Verification:
- env -u DEEPSEEK_API_KEY go test -count=5 ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -race -count=3 ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && env -u DEEPSEEK_API_KEY go test -count=1 ./...
- git diff --check
2026-07-22 01:58:43 +08:00
ef29d046fd feat(desktop): add line numbers and search to file preview (#6557) / 为文件预览添加行号边栏与搜索功能 (#6574)
* feat(desktop): add line numbers, virtual scroll, and search to file preview (#6557)

Replace the default HljsCode editor with LineNumberCode, which adds: - Line-number gutter (right-aligned, selectable via CSS user-select) - Virtual scrolling via @tanstack/react-virtual for files > 100 lines - Ctrl+F search bar with Codex-style controls: N of M counter, prev/next arrows, case-sensitive (Aa) and whole-word (ab) toggles - Inline <mark> highlighting of matched text within syntax-highlighted lines - Row dimming for non-matching lines during search

Applies to both the workspace file preview (WorkspacePanel) and chat tool output (ToolCard read_file / read_session), where the existing %*d arrow prefix is stripped before rendering to avoid double line numbers.

Also raise filePreviewLimit from 256 KB to 2 MB so the workspace panel can show complete files.

* Fix file preview search regressions

Problem:
The file preview viewer intercepted find globally, corrupted escaped-code searches, dropped copy controls, and lost multiline lexer state.

Root cause:
The line-number viewer became the default CodeViewer, reused a stateful regex across lines, and searched serialized highlight.js HTML.

Fix:
Scope the enhanced viewer to workspace previews, preserve original tool output, map raw match offsets onto balanced highlighted rows, restore copy controls, and keep the shortcut local to the focused viewer.

Verification:
- npm test
- npm run build
- Browser verification of workspace line numbers, scoped find, and copy layout
- git diff --check

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

* Document workspace preview limits

Problem:
The desktop editor documentation still described obsolete PlainCode seams, and localized truncation notices still reported the previous 256 KB preview limit.

Root cause:
The file preview implementation and backend limit changed without updating the developer documentation or user-facing locale strings.

Fix:
Document the opt-in searchable line-number viewer and 2 MiB preview behavior, update all supported locale notices, and add a contract test that keeps the backend limit and translations aligned.

Verification:
- tsx src/__tests__/workspace-preview-css.test.ts (21 passed)
- tsc --noEmit
- npm run check:css
- npm run build
- git diff --check

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

* Credit #6570 workspace preview contribution

Context:
PR #6570 independently established the workspace-only opt-in line-number approach that is retained and extended in PR #6574.

Attribution:
Credit the adopted scope boundary, adaptive gutter design, and non-selectable line-number behavior to the source PR contributor.

Verification:
- Compared both PRs against the latest main-v2
- Confirmed PR #6574 preserves the adopted workspace-only rendering boundary
- git diff --check

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

* fix(desktop): harden file preview search

Problem:
The file preview search skipped the first result, rebuilt the entire highlighted document on each query, and exposed untranslated or incomplete controls.

Root cause:
Search navigation advanced before the pending query committed, while matching and markup generation were coupled across the full source.

Fix:
Debounce and cap matching, inject marks only into rendered lines, use a plain-text fallback for large files, reveal the first result, and localize and label the search controls.

Verification:
- npm test
- npm run build
- go test ./...
- in-app browser search regression

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

---------

Co-authored-by: SivanCola <[email protected]>
Co-authored-by: youuoypi <[email protected]>
2026-07-22 00:10:31 +08:00
SivanCola b1dd67ea24 test(desktop): cover active topic archive guards (#6776)
Coverage gap:
The merged topic archive guard lacked direct regressions for active detached runtimes and the turn-admission race window.

Tests:
- verify a running detached runtime keeps its controller, binding, session, and topic metadata when archive is rejected
- force SubmitToTab to hold runtime admission while TrashTopic waits, then verify the active-work recheck rejects the archive atomically

Verification:
- go test . -run '^(TestTrashTopicRejectsRunningDetachedRuntime|TestTrashTopicWaitsForConcurrentTurnAdmission)$' -count=50
- go test -race . -run '^(TestTrashTopicRejectsRunningDetachedRuntime|TestTrashTopicWaitsForConcurrentTurnAdmission)$' -count=1
- go test ./...
- git diff --check
2026-07-21 23:41:44 +08:00
par73e 6dd57cce98 fix(desktop): prevent archiving active sessions (#6761) 2026-07-21 23:20:51 +08:00
c0ffe7108f fix(desktop): harden auto-update recovery / 加固桌面端自动更新恢复链路 (#6764)
* fix(desktop): unify updater state and network fallback

Problem: The startup banner and Settings used independent updater state, while manifest and signature requests could fail permanently on a broken IPv6 route.

Root cause: Each useUpdater caller owned a separate state machine, and only artifact downloads had an IPv4 fallback. A stalled metadata request could also consume the whole retry window.

Fix: Lift updater state into one provider, give manifest transports bounded attempt budgets, retry transient metadata failures over IPv4, and avoid retrying permanent HTTP statuses.

Verification: pnpm test:all; pnpm build; go test ./...; go vet ./...

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

* fix(desktop): harden platform update handoff

Problem: macOS could replace the app before the old process fully exited, and Windows surfaced an opaque fork/exec failure when the copied helper was blocked or invalid.

Root cause: The macOS handoff used a fixed one-second delay, while the Windows path copied and started the helper once without validating its PE image, architecture, or copied bytes.

Fix: Wait for the exact macOS PID, log and roll back failed relaunches, verify the Windows helper before handoff, retry transient security locks, and return actionable path-safe errors.

Verification: go test ./...; go vet ./...; Windows ARM64 tests and the stamped asInvoker helper executed successfully in a Parallels Windows 11 VM.

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

---------

Co-authored-by: HaoyueQin <[email protected]>
Co-authored-by: ttmouse <[email protected]>
2026-07-21 17:24:45 +08:00
SivanCola 7588ffe17c fix(desktop): Show Windows update progress / 显示 Windows 更新进度 (#6751)
* fix(desktop): show Windows update progress

Problem:
Windows auto-update closed Reasonix and ran NSIS silently, making a healthy handoff look like the installer never started.

Root cause:
The update helper always passed /S and hid the NSIS child window, so users had no feedback after the desktop exited.

Fix:
Add a dedicated /REASONIXUPDATE mode that keeps the verified in-place directory fixed, skips welcome and directory pages, displays localized progress, auto-closes on success, and preserves helper and Guard recovery. Update UI copy and lock the handoff contract with regression tests.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop/frontend && pnpm typecheck
- Windows amd64/arm64 desktop and helper cross-compilation
- makensis 3.12 installer compilation with three language tables

* fix(desktop): close Windows updater after install

Problem:
Windows auto-updates showed the visible installer progress but stopped on the Finish page, so the helper could not relaunch Reasonix without user input.

Root cause:
The update mode enabled automatic close on the install-files page, but the unconditional MUI Finish page still remained in the page sequence.

Fix:
Skip the Finish page only for /REASONIXUPDATE=1 while preserving the normal manual installer wizard, and add a packaging contract test for the page hook.

Verification:
- cd desktop && go test ./...
- Built the Windows ARM64 NSIS installer
- Ran the cached update helper in Parallels Windows 11 and verified visible progress, automatic installer exit, helper exit code 0, automatic relaunch, and reasonix v0.0.0-pr6751
2026-07-21 16:19:13 +08:00
SivanCola 2c27d62326 fix(desktop): improve first-run provider onboarding (#6757)
Problem:
- The first-run gate only checked for a DeepSeek key, so configured custom and no-auth local providers still saw an irrelevant setup modal.
- Users could not durably defer setup, and the alternative-provider action opened model usage instead of provider access.

Root cause:
- Onboarding eligibility was tied to one credential rather than the effective provider configuration.
- The frontend gate had no persisted dismissal state or explicit model-access target.

Fix:
- Accept any enabled, configured provider with chat models and restore DeepSeek access after successful quick setup.
- Add a versioned local dismissal marker, a persistent setup banner, and direct routing to Settings > Models > Access.
- Refresh the localized onboarding copy and cover backend and frontend contracts.

Verification:
- cd desktop && go test ./...
- cd desktop/frontend && pnpm exec tsx src/__tests__/startup-settings-contract.test.ts
- cd desktop/frontend && pnpm build
- Verified both onboarding and banner routes in the in-app browser.
2026-07-21 15:37:27 +08:00
SivanCola 3a815ed35d fix(docs): self-host the Star History chart (#6755)
Problem:
The README's third-party Star History image stopped rendering after GitHub restricted access to timestamped stargazer history.

Root cause:
The unauthenticated chart service can no longer read this repository's detailed stargazer data, so GitHub Camo receives an upstream failure.

Fix:
Generate accessible light and dark SVG charts in a trusted secret-backed workflow, publish them to a dedicated asset branch, and reference those repository-hosted images from both READMEs.

Verification:
- chart generator syntax and 5 unit tests passed
- actionlint and git diff checks passed
- full repository CI passed on Linux, macOS, and Windows
- race, CodeQL, desktop, site, and security checks passed
2026-07-21 14:39:40 +08:00
SivanCola 9cd6c67a6e docs: Add VS Code extension install paths / 添加 VS Code 扩展安装入口 (#6735)
* docs: add VS Code extension install links

Summary:
- document the Reasonix VS Code extension in both READMEs
- add Marketplace and Open VSX install paths to the website
- cover the new deep link and responsive download layout

Verification:
- cd site && npm test
- cd site && npx astro build
- Chromium checks at 1280px and 320px

* docs: remove obsolete rewrite banner

Context:
The top-level 1.0 rewrite notice describes main-v2 as a new default and duplicates migration guidance that remains linked in the documentation section.

Change:
Remove the transitional notice from both English and Chinese READMEs while preserving the dedicated migration guide links.

Verification:
- git diff --check
- confirmed both README migration links remain

* docs: clarify Reasonix install paths

Context:
The bilingual README grouped install channels without helping readers choose between CLI, Desktop, editor, and source workflows.

Change:
Reorganize installation guidance into explicit paths with a platform table and first-run steps. Keep project support details at the bottom in a collapsed section so they remain available without distracting from the documentation.

Verification:
- git diff --check
- verified release, desktop, extension, source, and donation links

* docs: simplify support footer

Context:
The collapsed support block added unnecessary interaction at the very end of the README.

Change:
Replace the disclosure control in both README variants with a subtle horizontal divider and compact centered label while keeping the support details visible.

Verification:
- git diff --check
- inspected the bilingual footer markup

* docs: separate CLI and desktop quick starts

Context:
The README quick-start mixed CLI commands with desktop usage and duplicated detailed configuration guidance already covered by the docs.

Change:
Keep a minimal CLI/TUI command flow, add a distinct desktop download-and-launch path, and replace the inline configuration example with links to the authoritative CLI and configuration guides.

Verification:
- git diff --check
- verified bilingual documentation targets
- verified the desktop download URL returns HTTP 200

* docs: streamline documentation links

Context:
The README documentation section presented a long flat list, and its embedded Star History image currently fails to load.

Change:
Group bilingual documentation links into three concise categories, surface the configuration-path guide, label English-only links in the Chinese README, and replace the broken chart embed with a stable page link.

Verification:
- git diff --check
- verified every referenced documentation file exists
- verified the Star History page returns HTTP 200

* docs: restore the Star History chart

Context:
The README should display the existing project trend chart by default instead of reducing it to a text link.

Change:
Restore the exact bilingual Star History picture markup currently used by the upstream online README, including light and dark theme sources.

Verification:
- git diff --check
- compared both restored sections byte-for-byte with origin/main-v2

* docs: keep README links language-specific

Context:
The Chinese README documentation index still linked to three English-only pages, making the language routing inconsistent.

Change:
Remove English-only Spec, migration, and checkpoint links from the Chinese index and retain them in the English README. Rename the remaining Chinese engineering group to match its contents.

Verification:
- git diff --check
- confirmed every Chinese index link targets a zh-CN document
- confirmed the English index contains no zh-CN links

* docs: add missing Chinese references

Context:\nThe Chinese README should keep the same documentation coverage as the English README without sending Chinese readers to English-only pages.\n\nChange:\nAdd Chinese engineering spec, migration, and checkpoints references, and restore their links in README.zh-CN.md.\n\nVerification:\nValidated Markdown fence balance, relative links, privacy-sensitive strings, and git diff whitespace.

* site: place VS Code install tab last

Context:
The VS Code install channel should follow the existing desktop and package-manager options in the homepage tab order.

Change:
Move the VS Code tab after Homebrew and add a source contract assertion for the channel order.

Verification:
Ran the complete site test suite and verified the deep link still selects the VS Code pane in the local browser preview.

* site: clarify editor extension install tab

Context:
The install pane covers both Visual Studio Marketplace and Open VSX, so a VS Code-only label is too narrow.

Change:
Rename the tab to Editor extension / 编辑器扩展 while preserving the vscode deep-link key, and add a bilingual copy contract assertion.

Verification:
Ran all site tests and verified the local deep link selects the renamed pane.

* site: streamline hero secondary links

Context:
The hero secondary install guidance was a dense sentence with weak scanability and low visual contrast.

Change:
Turn it into a concise bilingual “More ways to use Reasonix” navigation row with wrapping, clearer hierarchy, visible link affordances, and focus styling.

Verification:
Ran all site tests and verified the local homepage in the in-app browser.

* docs: clarify web dashboard recommendation

Context:
The migration guide said v2 no longer had a web dashboard, contradicting the supported `reasonix serve` browser UI.

Change:
Keep the web dashboard documented while recommending the Wails desktop app as the primary visual experience in both languages.

Verification:
- cd site && npm test
- git diff --check
- confirmed the obsolete no-dashboard wording is absent

* docs: clarify explicit Goal activation

Problem:
The Chinese engineering spec did not explicitly state that AutoResearch requires an explicitly started Goal.

Root cause:
The translated summary predated main-v2's rule that ordinary chat never changes collaboration mode or creates durable AutoResearch state.

Fix:
State that users must choose Goal or run /goal before long-horizon work can enable AutoResearch, and that ordinary chat stays ordinary.

Verification:
- cd site && npm test
- git diff --check
- compared the Chinese wording with the latest main-v2 Goal contract

* docs: clarify explicit Plan activation

Problem:
The Chinese engineering spec did not state that Plan Mode requires an explicit user choice.

Root cause:
The translation predated main-v2 retiring automatic Plan Mode.

Fix:
Document that ordinary chat cannot enter Plan automatically, that tool approval posture is independent, and that an approved-plan execution window cannot approve later plans.

Verification:
- cd site && npm test
- git diff --check
- compared the Chinese wording with the latest main-v2 Plan contract
2026-07-21 02:52:04 +00:00
SivanCola 222af64188 docs(release): prepare v1.17.17 notes
Add reviewed bilingual product notes for v1.17.17 and correct the affected product surfaces.

Verification:
- release-notes validation and rendering
- full pull request CI across Linux, macOS, and Windows
- public-content privacy scan
2026-07-21 10:09:27 +08:00
erphenheimerandSivanCola 993cee10f2 fix(controller): cancel foreground work during close / 关闭 Controller 时取消前台任务 (#6679)
* fix(controller): clear stale running/finishing/approval state on close / 清除关闭 Controller 时残留的运行/完成/审批状态

* Fix controller close cancellation lifecycle

Problem:
Controller.Close could leave a foreground turn or approval waiter alive, while the original PR cleared status bookkeeping independently of the goroutine lifecycle.

Root cause:
Close sealed admission but did not cancel the active foreground context. A late completion could also re-enter the finishing state after teardown.

Fix:
Cancel the captured foreground context after sealing admission, clear prompts only alongside that cancellation, keep running truthful until the goroutine exits, and prevent closed completions from reopening the finishing gate. Add focused regressions for pending asks and late TurnDone delivery.

Verification:
- go test ./internal/control
- go test -race ./internal/control
- go test ./...
- focused desktop runtime lifecycle tests
- git diff --check

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-21 09:30:01 +08:00
HaoyueQinandSivanCola 9eb9511f8b fix(desktop): prevent model list option overlap when provider has many models / 修复供应商模型较多时设置页面模型列表选项重叠 (#6539)
* fix(desktop): prevent model list option overlap when provider has many models

Removes the undersized min-height (32px) from .provider-model-draft__option
that was smaller than the actual minimum content height (~46px), causing
option cards to visually overlap when model names wrapped to multiple lines.

Adds overflow: hidden to prevent content bleeding, grid-auto-rows: min-content
to size each grid row to its tallest card, and bottom padding to prevent the
last row from touching the container edge.

Closes #5563, #5585, #5785, #6480

* test(desktop): guard provider model grid sizing

Context: PR #6539 fixes provider model card overlap by correcting implicit grid row sizing and removing the undersized card minimum.

Coverage: Assert that model rows stay content-sized, option cards keep no explicit minimum height, and unexpected overflow remains contained.

Verification: typography-overflow-contract.test.ts passed with 127 tests; CSS syntax and z-index checks passed; git diff --check passed.

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-20 14:40:34 +00:00
SivanCola 77fd1a47be fix: retire automatic Plan Mode / 退役自动计划模式 (#6734)
* fix: retire automatic Plan Mode

Problem:
Automatic Plan Mode created two different planning experiences, and users who had enabled it kept silently entering Plan Mode after the setting was removed.

Root cause:
The preference was persisted across TOML, desktop, CLI, controller, and classifier paths without a retirement migration or a single explicit-mode contract.

Fix:
Remove the setting and automatic classifier/runtime paths, migrate pre-v5 configs to manual-only behavior, retain safe read compatibility for old TOML and Wails clients, and update docs and metrics.

Verification:
- go test ./internal/config ./internal/control ./internal/boot ./internal/cli
- cd desktop && go test ./...
- pnpm build and pnpm test:typecheck in desktop/frontend
- settings-refresh-snapshot.test.tsx: 43 passed
- ./scripts/cache-guard.sh
- real-browser settings inspection with a clean console

* fix: remove obsolete desktop test helper

Problem:
The Linux desktop CI job failed golangci-lint after the Automatic Plan tests were removed.

Root cause:
A test-only turn runner and its context import were left behind without any remaining callers.

Fix:
Remove the obsolete helper and unused import.

Verification:
- cd desktop && go vet ./...
- cd desktop && go test ./...

* fix: preserve retired auto-plan compatibility

Problem:
Released desktop clients still submit the settings_auto_plan metric, and existing shell automation may still query or disable automatic Plan Mode.

Root cause:
The retirement removed both the worker enum value and the shell command, causing legacy metric batches to fail validation and safe compatibility commands to exit with an error.

Fix:
Keep settings_auto_plan as an input-only legacy metric signal, and retain a hidden config auto-plan shim that reports off, accepts off as a no-op, and rejects enable attempts. Add focused regressions for both contracts.

Verification:
- env -u DEEPSEEK_API_KEY go test ./internal/config ./internal/cli
- npm test in workers/crash-report
- npm run typecheck in workers/crash-report
- ./scripts/cache-guard.sh
- git diff --check

* fix: ignore unknown crash-report metrics

Problem:
A retired or future metric signal could invalidate an otherwise useful metrics batch, while a dedicated legacy allowlist only handled one historical signal.

Root cause:
The worker validated every counter signal with a strict enum before processing the batch.

Fix:
Keep strict validation for known counters, discard bounded unknown signals before storage, and treat an all-unknown batch as a successful no-op.

Verification:
- npm test (workers/crash-report)
- npm run typecheck (workers/crash-report)
- go test ./internal/cli -run TestConfigAutoPlanCompatibilityCommand|TestConfigLoadIgnoresRetiredAutoPlan -count=1
2026-07-20 21:51:51 +08:00
par73e 6ded2b3c29 fix(desktop): keep Windows opener menu above transcript (#6737) 2026-07-20 13:50:53 +00:00
SivanCola 8b44e4cee5 fix(desktop): show current workspace diffs (#6733)
Problem:
Selecting a changed file opened only its commit history, so users could not inspect staged, unstaged, untracked, or session-only edits inside Reasonix.

Root cause:
The Changes view reused the git history/detail APIs and checkpoints exposed metadata without an earliest pre-edit file state. There was no tab-scoped current-change contract for the frontend.

Fix:
Add a read-only current-change API that prefers the Git HEAD-to-worktree patch and falls back to the earliest session checkpoint. Render semantic line-numbered diffs with change totals, keep commit history secondary and collapsed, and reject stale same-tab session responses.

Verification:
- go test ./internal/checkpoint ./internal/control
- cd desktop && go test ./...
- pnpm build
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/workspace-changes-errors.test.tsx (30 passed)
- Real browser verification at 429 px with no console warnings or errors
2026-07-20 21:02:45 +08:00
SivanCola 6fcd41d2a9 Require explicit Goal start for AutoResearch / 要求显式启动目标模式后再启用自动研究 (#6731)
* fix(goal): require explicit Goal start for AutoResearch

Problem:
Ordinary prompts with strong long-horizon wording could silently switch the session into Goal + AutoResearch, create durable task state, and change the collaboration mode without an explicit user choice.

Root cause:
The controller default submit path and the TUI send path both invoked a dedicated ordinary-prompt AutoResearch classifier before routing a normal turn.

Fix:
Remove the implicit promotion paths, classifier, and interface while preserving AutoResearch selection inside explicitly started Goals, explicit --research/--simple flags, and existing task recovery. Update controller and TUI regressions plus user-facing documentation.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && go test ./...
- go test ./internal/cli -run TestStrongResearchPromptStaysInOrdinaryMode -count=1
- ./scripts/cache-guard.sh
- site tests and Astro static build

* test(goal): exercise task-path prompts through a real turn

Problem:
The AutoResearch task-path regression test constructed a controller without a runner, so it would also pass against the removed implicit auto-start implementation.

Root cause:
The legacy controller only mutated Goal state after detecting a task-path prompt when a runner was present, while the test asserted only the runner-less state.

Fix:
Wire the test through a scripted provider and real agent turn, wait for completion, and assert the original task-path prompt reaches the provider without Goal or AutoResearch injection.

Verification:
- env -u DEEPSEEK_API_KEY go test ./internal/control ./internal/cli
- env -u DEEPSEEK_API_KEY go test ./...
- git diff --check
2026-07-20 12:52:51 +00:00
zhuguang-ZFGandSivanCola 0051567cad fix(ccswitch): import MCP servers with enabled_reasonix (#6730)
* fix(ccswitch): import MCP servers with enabled_reasonix

CC Switch DB v16 tracks Reasonix MCP enablement in enabled_reasonix /
apps.reasonix. Prefer those columns when importing into Reasonix plugins,
with a transitional fallback to enabled_codex / apps.codex so older rows
still import until users flip the Reasonix toggle.

* fix(ccswitch): make Reasonix enablement backward-safe

Problem:
- The new enabled_reasonix query failed on legacy CC Switch databases and treated Codex enablement as active even when Reasonix was explicitly disabled.

Root cause:
- The importer referenced enabled_reasonix unconditionally and could not distinguish an absent legacy JSON flag from an explicit false value.

Fix:
- Detect the SQLite schema before choosing the Reasonix or legacy Codex filter, and preserve JSON field presence so explicit Reasonix enablement is authoritative.

Verification:
- go test -v ./internal/config -run Test\(CCSwitch\|LoadCCSwitch\)
- go test -race ./internal/config -run Test\(CCSwitch\|LoadCCSwitch\)
- go test ./internal/config ./internal/cli ./internal/control
- go vet ./internal/config ./internal/cli ./internal/control
- ./scripts/cache-guard.sh

* docs(ccswitch): clarify Reasonix fallback semantics

Problem:
- The importer comment and PR description implied that v16 rows could still fall back to Codex enablement.

Root cause:
- The documentation was not updated after fallback changed from a row-level OR to schema and field-presence detection.

Fix:
- Describe Reasonix enablement as authoritative when present and limit Codex fallback to pre-v16 SQLite schemas or legacy JSON without the Reasonix field.

Verification:
- go test -count=1 ./internal/config -run 'Test(CCSwitch|LoadCCSwitch)'\n- git diff --check

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-20 12:39:27 +00:00
SivanCola 4cec2b0fad fix(desktop): isolate WebView2 startup from stale proxies / 隔离 WebView2 启动残留代理 (#6727)
* fix(desktop): isolate WebView2 startup from stale proxies

Problem:
On Windows, Reasonix can remain invisible when a proxy client exits without clearing an unreachable system proxy. WebView2 stalls before DOM-ready while the Wails window is still StartHidden.

Root cause:
The WebView2 browser process inherits the Windows system proxy even though the desktop shell loads bundled assets through wails.localhost and remote workspaces through loopback URLs. There was also no visible timeout path when DOM-ready never arrived.

Fix:
Start the vendored WebView2 Chromium with --no-proxy-server for the embedded and loopback shell, while leaving Reasonix provider, updater, MCP, and SSH traffic on the separate Go proxy configuration. Add a 15-second Windows watchdog that shows the native window and a bilingual recovery prompt when startup remains incomplete.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop && wails generate module
- Windows amd64 and arm64 desktop test binaries cross-compiled successfully
- Windows amd64 vendored WebView2 edge tests cross-compiled successfully
- ./scripts/cache-guard.sh

* fix(desktop): preserve proxying for remote images

Problem:
Isolating the Windows WebView2 shell from the system proxy would also make remote Markdown and Mermaid image resources bypass Reasonix proxy settings. The startup watchdog could additionally fire in remote-window test contexts that do not carry a Wails frontend context.

Root cause:
Remote image URLs were still loaded by the WebView network stack, while the watchdog was enabled for every Windows WebView process instead of only the primary desktop window.

Fix:
Route remote Markdown and Mermaid image resources through a bounded, SSRF-guarded backend endpoint that uses the current Reasonix proxy specification, validates raster content, and sanitizes SVG. Scope the Windows startup fallback to the primary window only.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop && go test -race . -run 'RemoteMarkdownImage|WindowsWebView2StartupFallback|AwaitStartupFallback|RemoteWindowLifecycle' -count=1
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm typecheck
- cd desktop/frontend && pnpm build
- Windows amd64 and arm64 desktop test binaries cross-compiled successfully
- ./scripts/cache-guard.sh

* chore(security): document guarded image fetch

Problem:
CodeQL cannot infer that the remote Markdown image request is constrained by the custom URL, redirect, DNS, and dial-time guards, so it reports the intentional fetch sink as an uncontrolled critical request.

Root cause:
The security boundary is implemented across helper functions and a proxy-aware transport rather than a sanitizer recognized by the standard CodeQL data-flow model.

Fix:
Add the repository-standard scoped CodeQL annotation at the single guarded request sink, with the direct-dial and trusted-proxy DNS boundary documented inline.

Verification:
- cd desktop && go test . -run RemoteMarkdownImage -count=1
- cd desktop && go vet .

* chore(security): format CodeQL suppression

Problem:
The scoped CodeQL suppression was not recognized because its explanatory text shared the directive line.

Root cause:
CodeQL requires the codeql[query-id] directive to occupy its own comment line immediately before the guarded alert location.

Fix:
Keep the security-boundary explanation adjacent to the request and place the exact suppression directive on a separate line.

Verification:
- git diff --check
- gofmt desktop/remote_markdown_image.go

* chore(security): add legacy CodeQL suppression

Problem:
The modern source suppression directive remains visible to the audit but is not honored by the PR request-forgery query post-processing.

Root cause:
This CodeQL Go query configuration still requires the legacy lgtm query-scoped suppression marker.

Fix:
Add the exact legacy marker beside the modern directive at the single guarded request sink.

Verification:
- git diff --check
- gofmt desktop/remote_markdown_image.go

* chore(security): keep image guard rationale explicit

Problem:
Neither supported source suppression marker is honored by this repository's PR security summary for the intentional remote-image fetch.

Root cause:
The query does not model the custom proxy-aware SSRF transport and retains the alert independently of inline post-processing markers.

Fix:
Remove the ineffective directives while preserving the adjacent security-boundary explanation. The alert will be handled through the repository's auditable code-scanning triage record.

Verification:
- git diff --check
- gofmt desktop/remote_markdown_image.go

* fix(desktop): pin proxied markdown image targets

Problem:
Remote Markdown images skipped destination validation whenever a proxy was selected, valid SVGs with common prologs were rejected, and per-image transports could retain idle connections.

Root cause:
The image fetcher treated proxy-side DNS as a trust boundary, evaluated dynamic proxy routing more than once, relied on an SVG prefix heuristic, and created keep-alive transports that were never reused.

Fix:
Resolve every initial and redirected image target to public unicast addresses, select the proxy route once, and dial only vetted IPs through direct, HTTP(S) CONNECT, or SOCKS paths while preserving Host and TLS SNI. Make each transport single-use and close it with the response. Parse SVG roots after safely removing BOM, comments, and directives.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race . -run 'RemoteMarkdownImage|WindowsWebView2StartupFallback|AwaitStartupFallback|RemoteWindowLifecycle' -count=1
- cd desktop && go vet ./...
- frontend Markdown/Mermaid tests and pnpm typecheck
- ./scripts/cache-guard.sh
- Windows amd64/arm64 desktop and vendored WebView2 test binaries cross-compiled

* test(remote): stabilize encrypted identity fallback

Problem: The full race job intermittently timed out while testing fallback from a stored passphrase to a per-identity prompt.

Root cause: The integration test allowed only 10 seconds for an SSH handshake that performs three CPU-bound passphrase KDF operations, which is too tight under full race-package contention.

Fix: Give this single KDF-heavy integration scenario a 30-second context while leaving production timeouts and behavior unchanged.

Verification: GOMAXPROCS=2 go test -race ./internal/remote -run '^TestClientFallsBackFromStoredPassphraseToPerIdentityPrompt$' -count=10; go test ./internal/remote -count=3; go vet ./internal/remote.
2026-07-20 20:17:57 +08:00
SivanCola 8cb9e3c268 Align Remote SSH config and credentials with OpenSSH / 对齐远程 SSH 配置与凭据处理 (#6716)
* fix(remote): align SSH config and credential handling

Problem:
Remote SSH imports could freeze resolved fields, public-key authentication could stop before a configured identity was attempted, password-only hosts had no desktop prompt, and host removal did not provide a reliable confirmation flow.

Root cause:
Reasonix parsed only a subset of OpenSSH configuration, stored resolved values instead of the alias, represented multiple public-key sources as static methods, and lacked a one-shot desktop credential channel.

Fix:
Resolve effective host settings through ssh -G while preserving aliases and legacy entries, try ordered identity files through the SSH auth callback, add secure stored and one-shot desktop credentials, and make host removal explicit and testable.

Verification:
- go test ./internal/remote ./internal/cli
- cd desktop && go test ./...
- pnpm --dir desktop/frontend test:remote
- pnpm --dir desktop/frontend build
- git diff --check
- real OpenSSH and Reasonix connections succeeded against the same configured alias

* fix(remote): complete OpenSSH parity and credential cleanup

Problem:
Remote SSH still rejected valid Match exec configs, could redirect legacy hosts through a colliding display label, ignored IdentitiesOnly, reused one passphrase across encrypted keys, left CLI-managed credentials behind, and had Windows-only test failures.

Root cause:
The embedded parser gated the system OpenSSH resolver, host names doubled as alias metadata, auth cached passphrases at host scope, credential transactions lived only in the desktop package, and tests assumed Unix home paths.

Fix:
Make ssh -G authoritative while retaining parser fallback, use the persisted Host lookup key, honor IdentitiesOnly, cache and prompt passphrases per identity, share recoverable credential mutations across desktop and CLI, and make SSH discovery assertions platform-safe.

Verification:
Passed root and desktop Go suites, remote race tests, frontend typecheck/build and focused UI tests, real Match exec resolution, multi-key encrypted auth, and Windows root/desktop cross-compilation.

* fix(remote): complete OpenSSH parity edge cases

Problem:
Remote SSH still diverged from OpenSSH for system config loading, IdentityFile none, IdentitiesOnly agent keys, error handling, and repeated imports. Sequential credential prompts could also cross-resolve in the desktop UI.

Root cause:
Alias discovery eagerly resolved every host, effective-config failures fell back too broadly, agent auth treated IdentitiesOnly as a total agent disable, and prompt identity relied only on reusable metadata. Import replacement also rebuilt entries without Reasonix-specific settings.

Fix:
Resolve selected aliases lazily through the normal ssh -G config stack, propagate installed OpenSSH failures, preserve none semantics, filter agent signers by configured public identities, add opaque prompt IDs, and retain workspace/bootstrap settings on re-import.

Verification:
Passed full root and desktop tests and vet, remote race tests, frontend remote/type/build checks, cache/security checks, real ssh -G coverage, and Windows amd64/arm64 compile validation.
2026-07-20 15:58:09 +08:00
Lori CummingsandSivanCola 29151f33c9 feat(acp): support mid-turn steering / 支持 ACP 回合中引导 (#6715)
* feat(acp): support mid-turn steering

Expose the existing agent steer queue through a Reasonix ACP v1 session/steer extension. Accept guidance only while a prompt is active, preserve existing frontend fallback behavior, and replay delivered steers as the user's raw text.

Refs #3470

* fix(acp): namespace the steer extension

Problem:
The ACP v1 steer extension used the reserved core method name session/steer and was not discoverable through initialize capabilities.

Root cause:
The initial adapter registered the feature like a core ACP method instead of using ACP vendor extension naming and the capability _meta contract.

Fix:
Register _reasonix.io/session/steer, advertise it under agentCapabilities._meta["reasonix.io"], and lock the wire contract with tests.

Verification:
- go test ./internal/acp ./internal/control ./internal/agent
- go test -race ./internal/acp ./internal/control ./internal/agent
- go vet ./...
- env -u DEEPSEEK_API_KEY go test ./...
- git diff --check

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

* docs(acp): add editor integration guides

Documentation: Consolidate ACP v1 startup, capabilities, session lifecycle, controls, MCP transport, permissions, and mid-turn steering into dedicated English and Chinese guides.

Website: Surface ACP from both READMEs, the main guides, the bilingual docs page, and the homepage; keep deep links visible below the fixed header after language initialization.

Verification: npm test; npm run build; go test ./internal/acp ./internal/cli ./internal/control ./internal/agent; env -u DEEPSEEK_API_KEY go test ./...; go vet ./...; git diff --check; desktop and 390px Chrome DevTools checks in English and Chinese.

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-20 15:32:44 +08:00
SivanCola 610a4c3d43 fix(desktop): stabilize workspace tree virtualization (#6717)
Problem:
Workspace file rows could overlap and become unclickable after expanding or collapsing a directory while direct DOM positioning was enabled.

Root cause:
React keyed rows by file path, but TanStack Virtual defaulted to index keys. Inserting child rows shifted indices while the virtualizer cache still pointed at DOM elements reused by path.

Fix:
Provide the virtualizer with the existing stable workspace path key and add a runtime regression that verifies unique positions across expand, collapse, and re-expand transitions.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/workspace-changes-errors.test.tsx
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- Browser rapid-toggle check confirmed unique row positions without overlap
2026-07-20 14:19:50 +08:00
SivanCola 464d4942ad docs(release): prepare v1.17.16 notes
Release notes:
- add reviewed bilingual product notes for v1.17.16
- correct the affected product surfaces and remove two upstream issue-number collisions
- document Remote SSH platform limits and the one-time fleet schema cache cold start

Verification:
- release catalog validation and rendering tests
- site tests and production build
- citation range and privacy scans
2026-07-20 02:11:36 +00:00
SivanCola 43993f5a10 Fix plugin skill MCP tool bindings / 修复插件 Skill 的 MCP 工具绑定 (#6705)
* Fix plugin skill MCP tool bindings

Problem:
Plugin skills can reference raw MCP names or Claude-qualified names that are absent from Reasonix's provider-visible tool registry, causing otherwise valid tool calls to fail.

Root cause:
MCP registration canonicalized names, while skill imports preserved upstream instructions and allowed-tools metadata without a runtime binding layer.

Fix:
Add package-aware MCP capability bindings, resolve portable aliases only when unique, inject authoritative bindings when a plugin skill is invoked, canonicalize allowed-tools safely, and keep permission, hook, plan-mode, and audit decisions on the canonical name. Preserve provider schemas and use valid cached metadata for on-demand servers.

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

* Harden runtime MCP skill bindings

Problem:
The initial binding layer narrowed wildcard allowed-tools entries, trusted an authored Markdown heading as an idempotence marker, and could mix stale boot-cache entries with a live server surface.

Root cause:
Pattern translation replaced rather than augmented existing patterns, preparation state was inferred from untrusted content, and cached bindings were appended without checking the session registry's authoritative servers.

Fix:
Preserve wildcard allowlists while supplementing only unmatched canonical names, track preparation with host-only Skill state, and skip cached bindings whenever that server already has registry bindings. Add focused regressions for all three contracts.

Verification:
- go test ./internal/skill ./internal/boot ./internal/tool ./internal/agent ./internal/plugin ./internal/control
- go test -race ./internal/skill ./internal/boot ./internal/tool ./internal/agent ./internal/plugin ./internal/control
- go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

* Prepare MCP bindings for all subagent entries

Problem:
Structured multi-skill subagent invocations and the headless subagent profile API could pass plugin skills to their runners without runtime MCP bindings.

Root cause:
Those two host entry points bypassed the preparation calls already used by run_skill, slash rendering, and dedicated wrappers.

Fix:
Prepare each structured subagent immediately before dispatch and prepare headless profiles before selecting their runner. Add focused controller regressions for both paths.

Verification:
- go test ./internal/skill ./internal/boot ./internal/tool ./internal/agent ./internal/plugin ./internal/control
- go test -race ./internal/skill ./internal/boot ./internal/tool ./internal/agent ./internal/plugin ./internal/control
- go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check

* Scope on-demand MCP proxies in subagents

Problem:
A plugin subagent with an MCP-specific allowed-tools entry could lose that tool when the session exposed it only through use_capability. Granting the ordinary proxy would instead widen the skill to every capability.

Root cause:
Prepared allowlists carried only canonical direct tool names, and subagent registry filtering had no capability-specific proxy boundary.

Fix:
Carry stable mcp-tool capability IDs as host-only allowlist entries and synthesize a restricted use_capability wrapper only when no direct MCP binding exists. Enforce the ID boundary in both resolver and direct execution paths, including read-only subagents.

Verification:
- go test ./internal/skill ./internal/boot ./internal/tool ./internal/agent ./internal/plugin ./internal/control
- go test -race ./internal/skill ./internal/boot ./internal/tool ./internal/agent ./internal/plugin ./internal/control
- go test ./...
- go vet ./...
- ./scripts/cache-guard.sh
- git diff --check
2026-07-20 09:42:48 +08:00
SivanCola 2301e24827 Preserve interrupted turn recovery context / 保留中断轮次恢复上下文 (#6703)
* fix: preserve interrupted turn recovery context

Problem:
Stopping a turn or hitting a provider error discarded visible reasoning and tool progress, while the next turn could not safely determine which work had completed.

Root cause:
Cancellation cleanup removed the whole assistant/tool tail, and partial stream buffers existed only in frontend memory. Replaying those buffers directly would violate provider reasoning and tool-pairing contracts.

Fix:
Persist provider-excluded interrupted display records, retain only complete canonical tool pairs, and append a bounded structured recovery block to the next real user turn. Keep TUI and Desktop replay aligned, protect compaction and pruning paths, and add a legacy-safe orphan-tool sentinel.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/provider ./internal/agent ./internal/control -count=1
- go vet ./internal/provider ./internal/agent ./internal/control ./internal/cli
- pnpm exec tsx src/__tests__/use-controller-meta.test.ts
- pnpm typecheck
- ./scripts/cache-guard.sh

* fix: stabilize interrupted turns across compaction

Problem:
In-turn compaction could invalidate the recorded message index, duplicate a visible user request during recovery, and lose display-only interrupted output during explicit range summaries.

Root cause:
Recovery treated a mutable array index as a stable turn boundary, compaction could fold the active turn, and SummarizeFrom/SummarizeUpTo replaced LocalOnly records with their summaries.

Fix:
Relocate interrupted turns by their persisted start time or exact graceful fallback, preserve marker time across recovery branches, exclude the active turn from folds, and reattach LocalOnly records outside provider and archive inputs.

Verification:
go test ./...
go test -race ./internal/provider ./internal/agent ./internal/control -count=1
(cd desktop && go test ./...)
pnpm --dir desktop/frontend test:all
./scripts/cache-guard.sh
git diff --check

* fix: remove obsolete interrupted-turn wrapper

Problem:
The compaction-stability follow-up left an unreferenced one-argument cancellation wrapper, which failed the repository's golangci-lint unused check.

Fix:
Remove the dead wrapper and keep its recovery contract documented on the fallback-aware implementation used by the controller and orchestrator paths.

Verification:
go run github.com/golangci/golangci-lint/v2/cmd/[email protected].2 run --timeout=5m
go test ./internal/control
go vet ./internal/...
git diff --check
2026-07-20 01:16:31 +08:00
sudoviberandSivanCola 66ad2185af feat(desktop): 支持自定义对话框发送与换行快捷键 (#6503)
* fix(desktop): 点击快捷键录制按钮时显式聚焦,修复 WebKit 下按键无响应

桌面应用通过 WKWebView(Safari 内核)渲染,而 WebKit 点击 <button> 不会赋予其
键盘焦点;快捷键录制器的 keydown 监听恰好挂在按钮元素上,导致正式版中进入录制
状态后按任何组合键都毫无反应(开发时用 Chromium 内核浏览器测试掩盖了此问题)。
修复方式:进入录制状态时显式调用 focus(),并在按钮失焦时退出录制状态,避免界
面显示"按下组合键"实际却收不到按键。

* feat(desktop): 支持自定义对话框发送与换行快捷键

在快捷键注册表中新增 composer.send(默认 Enter)与 composer.newline(默认
Shift+Enter),均可在 设置 → 快捷键 中重新绑定。输入框的 Enter 判定改为:匹配
发送键则发送,匹配换行键则换行(默认 Shift+Enter 走浏览器原生插入,其余组合键
手动插入 "\n"),两者都不匹配的组合键不做任何事。未绑定任何动作的普通 Enter
仍插入换行,因此把发送键改为 Ctrl+Enter 后即为微信/钉钉式的操作习惯。发送按钮
悬浮提示与运行中的占位符文案通过 useShortcutComboLabel 实时显示当前绑定的组合
键,不再写死 "Enter"。

Closes #6343

* Fix composer shortcut recording boundaries

Problem:
The shortcut recorder accepted non-Enter keys that the composer runtime could not execute, and manual newlines after inline invocation badges lost their caret boundary.

Root cause:
Composer actions had no recorder-level key constraint, while newline insertion dropped the selection's afterInvocationId anchor.

Fix:
Restrict send and newline recording to Enter chords with localized validation, and preserve invocation ordering through a shared newline helper.

Verification:
- pnpm test
- pnpm run test:typecheck
- pnpm run typecheck
- git diff --check

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

* Fix composer shortcut compatibility and recorder exit

Problem: Configurable composer shortcuts made legacy modified-Enter send chords stop working under the default layout. The Enter-only recorder also consumed Tab and Escape, leaving keyboard users in recording mode.

Root cause: Unmatched modified Enter events were swallowed without preserving the previous default behavior, and recorder events were prevented before allowed-key validation and cancellation handling.

Fix: Preserve non-Shift Enter sending until the user explicitly customizes the send chord. Let Escape cancel recording and let Tab follow native focus traversal with a safe blur fallback. Add focused compatibility and keyboard-navigation regressions.

Verification: pnpm run test:all; pnpm run build; local browser keyboard interaction for Escape and Tab.

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-20 00:47:14 +08:00
SivanCola 2703d0116b Fix SSH host-key checks for multi-algorithm servers / 修复多算法 SSH 主机密钥误判 (#6699)
* fix(remote): handle additional host key algorithms

Problem:
Reasonix treated any key different from an existing known_hosts entry as a host-key replacement, even when the server presented a separate valid key algorithm.

Root cause:
The host-key callback only checked whether knownhosts.KeyError contained records; it did not distinguish same-algorithm key changes from a newly observed algorithm.

Fix:
Keep same-algorithm key changes fail-closed, but route keys from new algorithms through the existing fingerprint confirmation and managed known_hosts persistence flow. Add regression coverage for both paths.

Verification:
- go test ./internal/remote/... -count=1
- go vet ./internal/remote/...
- go test ./...
- go vet ./...

* fix(remote): prefer recorded host key algorithms

Problem:
Multi-key SSH servers could negotiate a host-key algorithm different from the one recorded in known_hosts, while accepting that different key through TOFU would weaken host identity verification.

Root cause:
The client used the Go SSH default host-key order instead of preferring algorithms already trusted for the destination.

Fix:
Restore fail-closed mismatch handling and derive a secure host-key preference order from matching known_hosts records before each target or ProxyJump handshake. Add real multi-key negotiation and mismatch regressions.

Verification:
- go test ./internal/remote/... -count=1
- go vet ./internal/remote/...
- go test -race ./internal/remote -run HostKey -count=1
- go test ./...
- go vet ./...

* fix(remote): preserve host key compatibility

Problem:
The host-key preference list removed legacy algorithms required by already trusted RSA-only hosts and treated certificate-authority keys as ordinary host identities.

Root cause:
Algorithm ordering was rebuilt only from secure algorithms, and knownhosts.KeyError.Want does not expose whether a matching record is marked @cert-authority.

Fix:
Preserve Go SSH legacy fallback algorithms behind secure choices, promote them only with matching recorded key formats, and inspect matched records with ssh.ParseKnownHosts so marker entries do not influence ordinary host-key preference. Add real reconnect and host-certificate regressions.

Verification:
- go test ./internal/remote/... -count=1
- go vet ./internal/remote/...
- go test -race ./internal/remote -run "TestNewSSHClient|TestHostKeyPolicy" -count=1
- go test ./...
- go vet ./...
- git diff --check
2026-07-20 00:20:20 +08:00
SivanCola 4bd70c9fc1 Streamline MCP install and launch authorization / 精简 MCP 安装与启动授权 (#6669)
* Fix MCP handshake and trust recovery

Problem:
- Stdio MCP servers could block initialization when they sent a request among startup notifications.
- Workspace re-verification could launch a second copy of a single-instance server, while first-time project approval was mislabeled as re-verification.

Root cause:
- The stdio read loop treated every inbound method message as a notification and never answered server requests.
- Desktop trust preflight ran before disconnecting the shared live client.

Fix:
- Distinguish notifications from server requests, answer ping and unsupported methods, serialize writes, and preserve the final message before EOF.
- Stop the live server before exact trust preflight, restore or reconnect shared registries safely, and label first-time approval as Authorize and connect.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/plugin
- cd desktop && go test -race . -run TestSetMCPTrustWorkspaceRefreshesEverySharedHostRegistry -count=1
- cd desktop/frontend && pnpm test && pnpm typecheck && pnpm test:typecheck
- go vet ./...
- GOOS=windows GOARCH=amd64 go test -c -o /dev/null ./internal/plugin
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh
- git diff --check

* Reply to MCP server requests off the read loop

Problem: readLoop answered server-initiated requests by writing to the
shared stdin pipe under writeMu. With both pipe buffers full, readLoop
could wait on writeMu held by a client call blocked on the jammed stdin,
while the server blocked writing the stdout that only readLoop drains — a
four-way deadlock. A failed reply write also killed the whole transport
even when the read side was still healthy.

Root cause: the only goroutine draining stdout was allowed to block on
the stdin write path.

Fix: route server-request replies through a bounded queue owned by a
dedicated replyLoop goroutine. readLoop enqueues without blocking and
drops replies on overflow, degrading to the pre-existing
unanswered-request behavior; reply write failures stop further replies
but are no longer terminal, since pipe errors keep surfacing through the
next client call's own write.

Verification: go test ./internal/plugin -race -count=1; focused
TestStdioReadLoopStaysLiveWhenReplyWriterIsBlocked and
TestStdioInitializeHandlesNotificationsAndServerPing -count=5 -race;
go vet ./internal/plugin; GOOS=windows GOARCH=amd64 go test -c
./internal/plugin; gofmt clean.

* Serialize MCP lifecycle mutations with trust preflight

Problem: SetMCPTrust snapshots each tab's enabled state and holds
runtimeRebuildMu through the trust preflight, but SetMCPServerEnabled,
ReconnectMCPServer, and the other MCP mutation entry points did not take
that lock. A disable issued while a workspace re-verification preflight
was in flight returned immediately, and the trust reconnect then
re-registered the server's tools from the stale snapshot — exposing
tools the user had just disabled and desynchronizing the
provider-visible registry.

Root cause: the trust preflight and the MCP enable/reconnect/edit entry
points mutated the same Host/Registry state under different locks.

Fix: route every Wails-exposed MCP mutation (inspect/set trust, add,
update, remove, reconnect, clear auth, enable/disable, tier, catalog
revocation apply) through lockMCPMutation, which serializes on
runtimeRebuildMu with the documented lock order runtimeRebuildMu ->
App.mu -> Host/Registry. Also refresh the stale transport interface
comment: server notifications are ignored, ping is answered, and
unsupported server requests get JSON-RPC method-not-found.

Verification: desktop go test ./... passed; focused
TestSetMCPTrustSerializesConcurrentDisable and
TestSetMCPTrustRestoresConnectionAfterPreflightFailure with -race
-count=3 passed; go build ./...; go vet; gofmt clean.

* Hold the MCP lifecycle lock across plugin uninstall

Problem: RemovePlugin disconnected the uninstalled plugin's MCP servers
through its OnDisconnect callback before rebuild() acquired
runtimeRebuildMu. Racing a workspace trust preflight, the unlocked
disconnect could interleave so the trust reconnect relaunched the
just-removed server from its stale snapshot, leaving an orphaned process
attached to the shared Host.

Root cause: the uninstall flow was the one remaining Wails MCP mutation
entry point outside lockMCPMutation.

Fix: RemovePlugin now holds the MCP lifecycle lock across the uninstall
and calls rebuildSettingLocked("plugins") so the rebuild does not
re-acquire runtimeRebuildMu. A desktop-wide sweep confirmed no other
live-MCP mutation site remains outside the lock.

Verification: TestRemovePluginSerializesWithTrustPreflight fails on the
unlocked implementation and passes with the lock (mutation-tested); the
four MCP lifecycle regressions pass with -race; desktop go test ./...
passed; go build ./...; go vet; gofmt clean.

* Make plugin uninstall atomic and clean every runtime

Problem: RemovePlugin checked for active runtime work before waiting on
the MCP lifecycle lock, so work starting during a lock wait (up to the
30s trust preflight) was only detected by the rebuild after the plugin
was already deleted — the user got a failure for an uninstall that had
happened. Its OnDisconnect callback also only disconnected the active
tab's controller: sibling tabs on the shared Host kept provider-visible
tools backed by the closed client, and other workspaces kept running the
uninstalled server.

Root cause: the pre-lock active-work check was trusted across the lock
wait, and the uninstall treated a global plugin as active-tab state.

Fix: RemovePlugin re-checks active work under the lifecycle lock and the
active tab's turn gate, holds that gate through the uninstall and
rebuild (rebuildSettingTurnLocked is the turn-gate-free split of
rebuildSettingLocked), and fails before deleting anything when work
started mid-wait. OnDisconnect now sweeps every visible and detached
runtime tab: DisconnectMCPServer stops the shared client once per Host
and drops the stale tool prefix from every other controller's registry.

Verification: TestRemovePluginRechecksActiveWorkUnderLock and
TestRemovePluginDisconnectsEveryRuntime both fail against the previous
implementation and pass with the fix (mutation-tested); the full
TestSetMCPTrust*/TestRemovePlugin* set passes with -race; desktop
go test ./... passed; go build ./...; go vet; gofmt clean.

* Gate every affected runtime during MCP trust and uninstall

Problem: RemovePlugin gated only the active tab's turn gate while
disconnecting every visible and detached runtime, so a background job on
a sibling tab let the uninstall complete and strip the running work of
its plugin MCP. SetMCPTrust checked that sharing controllers were idle
without holding their turn gates, so a turn starting between the check
and UnregisterMCPServerTools/DisconnectMCPServer lost its MCP tools
mid-flight.

Root cause: idle checks were done lock-free or under a single tab's gate
while the guarded mutation spanned every runtime sharing the state.

Fix: lockRuntimeTurnGates snapshots the affected visible and detached
tabs under runtimeRebuildMu, locks each turnStartMu in stable tab-ID
order, re-verifies active work under the gates (reading tab.Ctrl under
App.mu so detached runtimes count), and releases in reverse order.
RemovePlugin gates all runtimes across the uninstall and rebuild;
SetMCPTrust gates every tab sharing the Host across preflight, restore,
and reconnect. No path acquires turnStartMu before runtimeRebuildMu, so
the multi-gate order is deadlock-free.

Verification: TestRemovePluginRejectsBusySiblingRuntime,
TestRemovePluginRejectsBusyDetachedRuntime, and
TestSetMCPTrustWaitsForSiblingTurnGate all fail against the previous
implementation and pass with the gates (mutation-tested); the full
TestSetMCPTrust*/TestRemovePlugin* set passes with -race -count=3;
desktop go test ./... passed; go build ./...; go vet; gofmt clean.

* Freeze runtime work admission during MCP lifecycle mutations

Problem: lockRuntimeTurnGates assumed the runtime set was stable under
runtimeRebuildMu, but OpenTopicTab inserts tabs under App.mu only and
startTabControllerBuild attaches controllers from an unguarded goroutine.
A runtime appearing after the gate snapshot could start work while a
plugin uninstall was mid-flight, and during the 30s trust preflight a
newly built tab could attach to the same shared Host — relaunching a
single-instance server or leaving a registry the reverify never saw.

Root cause: the gates froze existing runtimes but nothing stopped new
work admission, so snapshot-then-gate had an unclosable enumeration race.

Fix: add runtimeAdmissionMu, a work-admission RW barrier. Turn admission
(beginTabTurn through finishTabTurnStart) and the async controller build
(buildTabControllerWithContext, covering the shared-Host attach and swap)
hold the read side; lockRuntimeTurnGates takes the write side before its
snapshot, making work admission — not tab-set stability — the invariant:
tabs created mid-hold stay inert until release. SetMCPTrust's affected
predicate now evaluates Host membership inside the gated snapshot and the
controllers list is computed after the gates, so controllers attached
just before the barrier are included. Writers acquire the barrier only
while holding runtimeRebuildMu (mutually exclusive), read holders never
acquire runtimeRebuildMu, and the order runtimeRebuildMu ->
runtimeAdmissionMu -> turnStartMu -> App.mu is one-directional, so the
barrier introduces no deadlock.

Verification: TestRemovePluginBlocksLateTurnAdmission (real beginTabTurn
path) and TestSetMCPTrustBlocksLateControllerBuild (real
startTabControllerBuild path) both fail with the barrier acquisitions
removed and pass with them (mutation-tested); the full
TestSetMCPTrust*/TestRemovePlugin* set passes with -race -count=3;
desktop go test ./... passed; go build ./...; go vet; gofmt clean.

* Harden MCP runtime lifecycle admission

Problem:
Concurrent turn admission, workspace repair, runtime teardown, and shared-host MCP edits could leak admission locks, deadlock behind a queued writer, close a captured Host mid-operation, drift to a newly active workspace, or leave sibling registries stale.

Root cause:
Admission ownership was split across callers, workspace repair recursively acquired the RWMutex read side, teardown unlinked some runtimes before entering the lifecycle barrier, MCP config helpers re-read active-tab state, and reconnect paths refreshed only one controller registry.

Fix:
Use an idempotent turn-admission token and admission-held build cores, extend the lifecycle barrier across unlink and teardown transactions, carry the invocation workspace root through config edits, and refresh every enabled controller sharing an MCP Host while preserving per-tab disabled state.

Verification:
go test ./...
cd desktop && go test ./...
go test -race ./internal/config ./internal/plugin
cd desktop && go test -race ./...
go vet ./...
cd desktop && go vet ./...
GOOS=windows GOARCH=amd64 go test -exec=true ./internal/plugin -run ^$
cd desktop && GOOS=windows GOARCH=amd64 go test -exec=true ./... -run ^$
./scripts/cache-guard.sh
git diff --check

* Fix desktop lifecycle lint findings

Problem:
The Desktop lint job rejected five dead admission wrappers left after the lifecycle refactor and an empty lock section in the queued-writer regression.

Root cause:
All production call sites had moved to admission-held cores, while the test used lock acquisition itself as an implicit signal that staticcheck could not observe.

Fix:
Remove the unused wrappers and publish an explicit channel signal while the writer owns runtimeAdmissionMu.

Verification:
cd desktop && go test -race -run ^TestBeginTabTurnWorkspaceRepairDoesNotRecursivelyLockAdmission$ -count=5
cd desktop && go test ./...
cd desktop && go vet ./...
git diff --check

* Stabilize MCP trust recovery and runtime teardown

Problem:
- Project MCP launch approval could invalidate itself after the first restart.
- Reconnect and trust recovery could select a shadowed user entry instead of the effective project entry.
- Provider teardown and shutdown could race runtime/MCP lifecycle work and leak shared-host references.

Root cause:
- Reasonix-owned offline launcher flags were included in the approved server identity.
- Lifecycle reloads used the edit-source lookup instead of merged runtime precedence.
- Several teardown paths bypassed the runtime admission barrier and did not release tab host ownership.

Fix:
- Separate canonical launcher identity arguments from enforced process arguments for npx, bunx, and uvx.
- Reload effective merged MCP entries for reconnect, toggle, and trust restoration.
- Serialize provider teardown and shutdown with runtime admission, gate live turns, and release affected shared hosts.
- Add deterministic regression coverage for authorization stability, source shadowing, lifecycle ordering, and host cleanup.

Verification:
- go test -race ./...
- cd desktop && go test -race ./...
- go vet ./... in both Go modules
- frontend tests, test typecheck, and production build
- cache guard
- Windows and Linux cross-compilation of affected packages

* Close MCP trust and runtime lifecycle gaps

Problem:
- Runtime teardown could close a controller while a foreground turn was still entering it.
- A stored uvx --from launcher lock split the option from its package value.
- Revoking project MCP trust left the authorized process alive and hid the recovery action.

Root cause:
- Ordinary teardown used the shared admission side, launcher enforcement inserted at the locator value, and connected revocation only updated trust receipts.

Fix:
- Serialize teardown with the exclusive runtime mutation barrier, keep uvx --from pairs adjacent, and disconnect every shared registry while preserving an actionable launch-approval status after revocation.

Verification:
- go test -race ./... (root and desktop modules)
- go vet ./... (root and desktop modules)
- pnpm test:all && pnpm build
- ./scripts/cache-guard.sh
- make cross and Windows desktop test compilation

* Synchronize job migration notice test

Problem:
- Linux race CI found concurrent writes to a test-owned notice slice while exercising artifact migration failure reporting.

Root cause:
- The manager can emit migration and completion notices from different goroutines, but the FuncSink test collector appended and read the slice without synchronization.

Fix:
- Guard notice collection with a mutex and inspect a synchronized snapshot.

Verification:
- go test -race ./internal/jobs -run ^TestSetActiveSessionPathReportsMigrationFailure$ -count=100
- go test -race ./internal/jobs -count=10
- go test -race ./...
- go vet ./...

* Bound bot config concurrency regression

Problem:
- Windows Go 1.26.5 CI repeatedly crashed with found pointer to free object while this lock-discipline test ran, including on main-v2 and unrelated pull requests.

Root cause:
- The test used a stop-driven background writer whose total work depended on reader and runner timing. A prior 1 ms throttle reduced but did not eliminate the unbounded allocation and GC pressure.

Fix:
- Start fixed reader and writer loops together, yield between iterations, and wait explicitly for the bounded writer to finish. The loops remain unordered so race detection still covers missing map locks.

Verification:
- focused normal test count=500
- focused race test count=200
- internal/bot normal count=20
- internal/bot race count=5
- go test -race ./...
- go vet ./...

* fix(mcp): simplify install and authorization flow

Problem:
Installing an MCP server could still lead to redundant launch and tool trust prompts, while the desktop surfaced low-level trust, isolation, and catalog maintenance controls during normal setup.

Root cause:
Project-scoped install_source entries lost their live provenance, durable launch grants did not imply ordinary-call approval, and cached placeholders were built before the established grant was applied.

Fix:
Preserve install provenance, record and roll back durable project launch grants, apply exact matching grants to live and cached tools, and reduce the desktop and CLI setup flow to add-and-connect with one confirmation only for repository-discovered servers.

Compatibility:
No persisted schema or config fields changed. Existing receipts and legacy trust, untrust, verify, and catalog commands remain supported, and MCP tool names, schemas, and registration order stay unchanged.

Verification:
- go test ./...
- pnpm test:all
- pnpm build
- git diff --check

* refactor(mcp): remove legacy trust workflow

Problem:
MCP installation and use still exposed session trust receipts, capability snapshots, re-verification, revocation, and catalog maintenance steps that interrupted normal development workflows.

Root cause:
Launch authorization and tool-call safety were coupled to a large trust state machine, so user-configured servers and repository-discovered servers shared unnecessary security UI, CLI commands, event payloads, and persisted authority.

Fix:
Replace the trust state machine with exact project launch grants, remove legacy Desktop and CLI trust surfaces, keep add-and-connect direct for user-configured servers, preserve old raw state for cross-version compatibility, and retain destructive approval, sandbox, signed revocation, and explicit read-only boundaries.

Verification:
- go test ./... and go vet ./... in root and desktop modules
- pnpm test:all and pnpm build
- focused race tests for MCP launch, plugin, agent, authorization, reconnect, and update paths
- cache guard and Windows cross-compilation
- browser verification of the simplified MCP settings flow
- git diff --check

* fix(mcp): allow incomplete drafts to return to form

Problem:
Switching an untouched or structurally valid incomplete MCP JSON draft back to the form editor was blocked.

Root cause:
Mode switching reused submission-level validation, which requires a non-empty command or URL even for the generated empty template.

Fix:
Allow incomplete connection fields only while converting JSON drafts back to the form, preserve untouched form state, and keep final submission validation strict. Add regression coverage for empty and edited incomplete drafts.

Verification:
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- pnpm typecheck
- pnpm test:typecheck
- Manual in-app browser mode-switch verification

* fix(mcp): honor install authorization for destructive tools

Problem:
MCP servers explicitly installed or launch-authorized by the user still prompted again whenever a tool declared destructiveHint, preventing the intended add-and-use experience.

Root cause:
The MCP permission gate evaluated destructive metadata before the effective approve policy, so the source-aware authorization default could never apply to those calls.

Fix:
Keep explicit deny as the highest priority, then let an effective approve policy run directly before destructive review. Preserve fresh review for destructive calls under auto, prompt, and writes; update lazy safety messages, tests, and public documentation to match the policy.

Verification:
- go test ./...
- go test ./internal/permission ./internal/control ./internal/plugin ./internal/agent
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- pnpm typecheck
- pnpm test:typecheck
- git diff --check

* fix(mcp): remove overwritten launcher preparation

Problem:
The PR lint job failed with staticcheck SA4006 in authorizeSpecLaunch.

Root cause:
The prepared spec was initialized before an if/else where both branches assigned it again, making the initial value unused.

Fix:
Declare the prepared spec without the redundant initial assignment while preserving both launch preparation paths.

Verification:
- go test ./internal/plugin
- go vet ./internal/plugin
- git diff --check

* test(mcp): make launch checks portable on Windows

Problem: Windows CI failed on POSIX-only permission semantics and could not remove a hard-linked running test executable.

Root cause: the tests assumed Unix mode bits and reused the active test binary as an MCP launcher fixture.

Fix: limit the permission-bit assertion to non-Windows hosts and use an inert executable fixture that is never held open by the test process.

Verification: go test ./internal/mcplaunch ./internal/plugin; go test ./...; GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c for both affected packages.
2026-07-19 23:59:41 +08:00
SivanCola 11733581f6 Fix Remote SSH error recovery UX / 改进远程 SSH 连接错误交互 (#6700)
* fix(desktop): improve Remote SSH error recovery

Problem:
- Raw SSH connection errors appeared in a global toast that could stretch across the macOS title bar and exposed machine-local diagnostics in primary chrome.

Root cause:
- The connect-and-open path forwarded backend error strings directly to the global toast, while host-key mismatch details were only available as unstructured text.

Fix:
- Preserve structured host-key mismatch details across the remote kernel and Wails bridge.
- Route connection failures to the window-level Remote SSH status popover with localized summaries and recovery actions.
- Add an explicit security details dialog and constrain generic toasts below the macOS title bar.

Verification:
- go test ./... && go vet ./...
- cd desktop && go test ./... && go vet ./...
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend test:remote
- pnpm --dir desktop/frontend build

* fix(desktop): recover remote connection edge states

Problem:
- Remote connection waits could time out without a visible recovery action, and degraded connections were presented as failed.

Root cause:
- The frontend could not distinguish waiter-only timeouts from backend terminal errors, and any status carrying an error was promoted to connection failure.

Fix:
- Add a typed timeout with a localized stop-and-retry action.
- Treat only stopped errors as terminal failures and render degraded SSH sessions as connected warnings.
- Apply safe localized summaries across the status bar, remote panel, and host manager.

Verification:
- cd desktop && go test ./... -count=1 && go vet ./...
- pnpm --dir desktop/frontend test:all
- pnpm --dir desktop/frontend build
- git diff --check
2026-07-19 15:59:20 +00:00
SivanCola d8d3e5e09c Fix digit-leading custom provider credentials (#6701)
Problem:
Custom providers whose names begin with a digit generated an api_key_env such as 9ROUTER_API_KEY. Credential validation rejected that name, so model discovery and manual provider saves both failed.

Root cause:
The desktop and CLI provider-name normalizers allowed a numeric first character even though Reasonix credential keys require an alphabetic or underscore prefix.

Fix:
Prefix digit-leading generated stems with CUSTOM_ in both surfaces, add regression coverage for 9router, and document the generated name in English and Chinese.

Verification:
- pnpm typecheck && pnpm test (desktop/frontend)
- go test ./internal/config ./internal/cli
2026-07-19 23:53:01 +08:00
SivanCola 8bb0e5493a Fix theme pack state after settings refresh (#6694)
Problem:
- Saving conversation width or another setting could replace an active theme pack's effective base palette with the configured restore palette.

Root cause:
- The generic settings refresh reapplied configured desktop appearance without reapplying the active theme pack.

Fix:
- Centralize configured base appearance application while preserving the current theme pack.
- Reuse the helper for direct theme-mode changes and add a regression test for refresh and restore behavior.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/theme-pack.test.ts
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- Browser: applied Spark Notebook, switched conversation width to Full, and confirmed the pack and Aurora effective style remained active.
2026-07-19 23:01:24 +08:00
SivanCola 65fcd46525 Add a Remote SSH module (remote serve + tunnel) / 新增远程 SSH 模块(远端 serve + 隧道) (#6673)
* feat(serve): support --port-file/--token-file/--pid-file for supervised headless serve

Extract RunGracefulListener so callers can bind first (--addr 127.0.0.1:0),
record the real port, and keep the auth token out of argv. Groundwork for the
remote-SSH module's detached remote serve bootstrap.

* feat(config): add user-global-only [remote] section for SSH hosts

[[remote.hosts]] entries carry connection/forward/workspace settings and name
credential env vars (passphrase_env/password_env) instead of secrets, mirroring
the provider api_key_env idiom. Like [secrets], loadForRoot pins the section
back to the user-global value after the project merge — with a deep copy,
because TOML decoding mutates slice backing arrays in place and a shallow pin
would alias the project-injected hosts. Remote credential env names flow into
CredentialEnvNames so they are filtered from tool subprocess environments.
Adds RemoteStateDir/RemoteKnownHostsPath and the internal/store name helpers
for the remote-side serve state layout.

* feat(remote): SSH transport kernel — dial, auth, host keys, forwards, SFTP

New internal/remote package group provides the connection layer for the
remote module:

- client.go: supervised connection with a single-goroutine state machine,
  keepalive ([email protected]), full-jitter exponential-backoff
  reconnect, shared SFTP (generation-checked), and forward re-attach.
- auth.go: OpenSSH-like method order (agent, identity file, default
  identities, password, keyboard-interactive); interactively-obtained
  secrets are cached in-process so reconnects never re-prompt.
- knownhosts.go: system known_hosts read-only + a Reasonix-managed TOFU
  file; key mismatch is a non-promptable hard error.
- dial.go: ProxyJump chains (netclient proxy applies to the first hop only).
- forward/: -L (persistent local listener across reconnects) and -R
  (re-registered on each attach) with typed ErrBindBusy/ErrDuplicateForward.
- sftpfs/: list/stat/capped-read-with-text-detection/atomic-write/mkdir/
  rename/remove, quarantining the new github.com/pkg/sftp dependency.
- netclient/dialer.go: StreamDialer honoring the user's proxy policy
  (SOCKS5 via x/net/proxy, http/https via CONNECT).
- sshconfig.go: ~/.ssh/config import via github.com/kevinburke/ssh_config.
- sshtest/: in-process SSH+SFTP server for tests.

New deps: github.com/pkg/sftp, github.com/kevinburke/ssh_config (both pure
Go). x/* versions kept at main-v2's pins.

Cache-impact: none — no prompt/tool/provider path touched.
Cache-guard: n/a (new leaf subsystem, not in the system-prompt prefix).

* feat(remote): remote serve bootstrap over SSH

internal/remote/bootstrap ensures a detached 'reasonix serve' runs for a
(host, workspace) pair: reuse a live process (the reconnect path), else detect
the remote OS/arch (uname), locate/install/upload reasonix, write a 32-byte
token 0600 via SFTP, launch serve bound to 127.0.0.1:0 with --token-file/
--port-file (token never in argv), poll the port file, and persist ServeState
JSON. Status/Stop/Logs manage the recorded process. Every remote command
operand is POSIX single-quote-escaped (golden test covers '; rm -rf ~').
V1 targets Linux/macOS remotes; cross-platform upload without a matching local
binary is a documented limitation directing to serve_install = npm.

Cache-impact: none — new leaf subsystem, no prompt/tool/provider path.
Cache-guard: n/a.

* feat(cli): reasonix remote command group + /remote slash

Adds the remote subcommand tree (add/list/remove/import/test/connect/open/
status/forward/serve/fs), mirroring the mcp command shape. Host CRUD writes to
the user-global config only (remote hosts are pinned there); connect runs a
foreground supervisor that dials, bootstraps remote serve, forwards the serve
port with the token in a loopback URL, applies configured -L/-R forwards, and
holds the tunnel until Ctrl-C while the remote serve keeps running. Terminal
prompts handle key passphrases, passwords, and TOFU host-key confirmation;
dialing honors the user's proxy settings via netclient. The /remote slash
command shows a read-only host summary. New i18n fields added to all three
catalogs.

Cache-impact: none — CLI surface + i18n only, no prompt/tool/provider path.
Cache-guard: n/a.

* feat(desktop): remote SSH Go bindings (remote_app.go)

25 App bindings behind a narrow remoteKernel interface (fake-injected in
tests): host CRUD (user-global config), Connect/Disconnect/status,
status-driven TOFU host-key confirmation (ConfirmRemoteHostKey resumes a
parked dial), SFTP list/read/write-with-mtime-conflict/mkdir/rename/delete,
port-forward CRUD, and remote serve EnsureServer/Open/Stop/Status/Logs.

desktopRemoteManager wraps internal/remote per host; status/forward/server
transitions bridge to remote:status / remote:forwards / remote:server through
the asyncRuntimeEmitter (never blocking the kernel). The manager is created
lazily on first use and closed in App.shutdown, rejecting any pending host-key
prompt. OpenRemoteWorkspace forwards the serve port and opens the tunneled URL
via Wails BrowserOpenURL; last workspace/host persist to desktop-remote.json
(all-optional fields, backward-compatible).

desktop/go.mod gains pkg/sftp + kevinburke/ssh_config via go mod tidy.

Cache-impact: none — desktop bindings only, no prompt/tool/provider path.
Cache-guard: n/a.

* feat(desktop): remote SSH frontend (host manager, explorer, ports, server)

- lib/types.ts: Remote* TS mirrors + SettingsTab "remote".
- lib/bridge.ts: 25 Remote* AppBindings + browser-dev mock + onRemoteStatus/
  onRemoteForwards/onRemoteServer subscribers with a mock fan-out for tests.
- store/remote.ts: zustand store; pending_hostkey status drives the global
  fingerprint dialog and resolves on the next non-pending status.
- RemoteHostsPage (Settings > Remote SSH tab): host list with status chip,
  connect/disconnect/open, add/edit form, ssh_config import picker.
- RemotePanel: overlay drawer with Files (lazy tree + textarea editor with
  mtime-conflict Reload/Overwrite), Ports (live forward rows + add form), and
  Server (bootstrap phases + open-in-browser + logs) tabs.
- RemoteHostKeyDialog: global TOFU modal off the store's pendingFingerprint.
- StatusBar remote chip (worst live state, click opens explorer); App.tsx
  subscribes remote:* app-wide and renders the panel + dialog.
- i18n keys added to en / zh / zh-TW (key sets stay aligned).
- Editor is a monospace textarea (no CodeMirror/Monaco) per lean-deps.
- Tests: src/__tests__/remote-store.test.ts (added to the test script).

Cache-impact: none — desktop frontend only.
Cache-guard: n/a.

* docs: document the Remote SSH module (SPEC, GUIDE, CONFIG_PATHS, CHANGELOG)

Adds the internal/remote layout + acyclic dependency note to SPEC 2, a bilingual
Remote SSH section to GUIDE.md / GUIDE.zh-CN.md (config, CLI, reconnect/persistence
semantics, host-key TOFU, remote-side state layout, desktop UI), the managed
known_hosts path to CONFIG_PATHS(.zh-CN), and a CHANGELOG entry.

Cache-impact: none — docs only.
Cache-guard: n/a.

* chore: ignore stray .reasonix state under package dirs

A bootstrap test whose RealPath resolved to CWD once wrote a .reasonix/remote/
token under the package dir and it was swept into a commit. The offending file
has been scrubbed from history; this guards against recurrence.

* fix(remote): CI green — errcheck + cross-platform bind-busy and Windows test skips

- client.go: check cl.Wait() return (errcheck lint).
- forward: classify WSAEADDRINUSE as bind-busy on Windows (build-tagged
  addrinuse_windows.go), so ErrBindBusy is reported cross-platform.
- sftpfs / bootstrap integration tests skip on Windows: their in-process SFTP
  server serves the LOCAL filesystem, so on Windows it serves drive paths and
  the POSIX-remote path translation breaks — a harness limitation, not a
  product one (V1 remotes are Linux/macOS). Linux/macOS CI covers the flows.

Cache-impact: none — test/lint-only + a Windows errno check.
Cache-guard: n/a.

* test(remote): skip EnsureServe reuse/relaunch/stop tests on Windows at setup

TestEnsureServeReusesLiveProcess and siblings pre-write state via pathsFor()
(POSIX path.Join) before newFakeConn runs, so on Windows the temp-dir remote
home yields a malformed drive path and the setup os.WriteFile fails. Move the
Windows skip to the top of each such test (shared skipOnWindows helper) so it
fires before any pathsFor/os setup, not just inside the harness ctor.

* fix(remote): address review — P1 security / data-integrity / lifecycle

Resolves the 14 findings from code review (all verified against the code):

Security
- Serve token no longer leaks to the remote log: runServe suppresses the
  token share line under --port-file (supervised), and the launch script
  creates the log 0600 (umask 077 + chmod). (F1)
- ProxyJump no longer sends the target's password to jump hosts: each hop gets
  its own credential-free AuthOptions with a per-hop cache (persisted on the
  Client so reconnects don't re-prompt). (F2)
- CLI surfaces a misconfigured proxy instead of silently dialing direct; the
  https-proxy path now TLS-wraps the connection before CONNECT; desktop passes
  the proxy Dialer it previously omitted. (F14)

Data integrity
- `remote fs get` streams the full file via sftpfs.Download instead of the
  4 MiB-capped preview reader that silently truncated. (F3)
- Workspace slug carries an FNV hash of the exact path, so /srv/a-b and
  /srv/a/b no longer share serve state files. (F10)
- Desktop host edit merges: passphrase_env/password_env/forwards (absent from
  the desktop input) are preserved instead of wiped. (F7)

Lifecycle / correctness
- Bootstrap gates on `serve --help` advertising --port-file, not a version
  number (the flags ship in this change, so no released version qualifies). (F4)
- Launch uses setsid only when present, so stock macOS (no setsid) works. (F5)
- Desktop no longer uploads the GUI binary as the remote CLI (LocalBinary=""
  makes the upload path fail clearly toward npm). (F6)
- Serve tunnel re-binds to the current serve address on every EnsureServer and
  is torn down on Stop; per-host serveMu serializes concurrent ensures. (F8)
- Reuse/Stop verify the pid is actually a reasonix serve (ps args), not just
  kill -0, so a recycled PID is never mistaken or signalled. (F9)
- Desktop connect retries after failure (failed Start drops the managed host)
  and builds a fully-populated host under the lock so a concurrent Disconnect
  can always close it. (F11)
- SSH handshake is bounded by a conn deadline (ClientConfig.Timeout does not
  cover NewClientConn), so a banner-less host can't hang the dial or Close. (F12)
- ScanSSHConfig returns a non-nil slice so an empty result encodes as [] not
  null. (F13)

Tests: per-hop auth isolation, full-file download vs preview cap, slug
collision (/srv/a-b vs /srv/a/b), serve-alive/launch/locate command shape,
desktop edit-merge and non-nil scan. go test ./... green in both modules;
gofmt/vet clean; Windows cross-build green; cache-guard pass.

Cache-impact: none — no prompt/tool/provider/memory path touched.
Cache-guard: ./scripts/cache-guard.sh — all cases pass.
System-prompt-review: not required — no system-prompt-feeding config field changed.

* Harden Remote SSH lifecycle and packaging

Problem:
Remote SSH could misapply ProxyJump credentials, race connection and service lifecycles, interrupt live forwarding during replacement, and ship Desktop builds without the CLI sidecar required by upload installation. Several UI failures were also silent or left stale state behind.

Root cause:
Jump aliases shared endpoint-keyed authentication, bootstrap ownership relied on weak state checks without cross-process serialization, and Desktop operations lacked generation-scoped cancellation. Forward replacement stopped the working listener before the replacement was ready, while packaging and updater contracts did not include the CLI binary.

Fix:
Resolve and authenticate every SSH hop independently, make dialing and Desktop operations context-aware, serialize bootstrap with an atomic remote lock, verify exact process ownership, and make forward replacement failure-atomic. Harden token, SFTP, agent, config, and ssh_config handling; package and update the CLI sidecar on macOS, Windows, and Linux; and surface actionable validation and lifecycle errors in the Desktop UI.

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race for Remote, CLI, store, and Desktop modules
- go vet ./... for root and Desktop modules
- pnpm test:all and pnpm build
- Windows amd64 cross-compilation for Remote, bootstrap, forwarding, SFTP, and CLI packages
- clean merge snapshot against latest origin/main-v2 with root, Desktop, and frontend checks passing

* Add native Remote SSH workspace windows

Problem:
Remote SSH was buried in settings, and opening a remote workspace handed the authenticated loopback URL to the system browser instead of preserving a desktop workspace experience.

Root cause:
The desktop had no window-level Remote SSH control and no isolated Wails launch mode for a second native workspace window.

Fix:
Add a VS Code-style status entry, host switcher, command actions, and Remote dock. Launch remote workspaces in a lightweight Reasonix process that skips local session ownership, single-instance focus, tray services, and Wails bindings. Pass the loopback URL through a one-shot permission-restricted ticket so the token never appears in process arguments.

Verification:
- go test ./...
- pnpm test:all
- pnpm build
- wails build -clean
- native macOS multi-window smoke test
- git diff --check

* Tidy Remote SSH module metadata

Record the SSH config and SFTP packages as direct dependencies after rebasing onto the latest main-v2 baseline. Remove stale module checksums so the root module remains tidy in CI.

* Fix Remote SSH Windows test assumptions

Load the fixture SSH config by its explicit path while setting both platform home variables, and use the Windows .exe suffix for the desktop CLI fixture. This keeps the Remote SSH coverage portable across the CI matrix.
2026-07-19 21:48:20 +08:00
SivanCola 0a54d50453 Fix reliable session image/PDF exports / 修复会话图片与 PDF 导出 (#6685)
* fix(desktop): make session image and PDF exports reliable

Problem:
Desktop image and PDF exports could silently fail, and long sessions exceeded practical canvas limits.

Root cause:
SVG foreignObject content loaded through a blob URL tainted the export canvas in older embedded Chromium/WebView builds. The previous implementation also rasterized the entire session into one oversized bitmap.

Fix:
Render self-contained SVG data URLs, neutralize external resources, split long PNG exports at natural content boundaries, and build PDFs from independently rasterized pages. Surface success and failure feedback and add frontend and Go regression coverage.

Verification:
- pnpm test
- pnpm test:typecheck
- pnpm typecheck
- pnpm build
- cd desktop && go test ./...
- browser smoke test with a 34k-token session

* Harden multi-part session exports

Problem:
Long PNG exports could overwrite unconfirmed numbered sibling files, and external Markdown images loaded before sanitization could shift the measured export layout.

Root cause:
The frontend derived and wrote image parts sequentially, while media sanitization only ran on cloned DOM after the source surface had already rendered.

Fix:
Move multi-part image persistence into an exclusive staged backend batch with rollback, and sanitize initial Markdown image rendering to an allowlist of inline raster data before waiting for image decode and pagination.

Verification:
go test ./... in the root and desktop modules; go vet ./... in both modules; pnpm test; pnpm test:typecheck; pnpm typecheck; pnpm build; Wails binding generation.

* Harden final session export edge cases

Problem:
Image and PDF export could still alter a native-dialog path, publish files with inconsistent modes, remove a replaced target during rollback, append a blank trailing page, filter safe inline images, expose selected directories in errors, and retain all PNG blobs during base64 conversion.

Root cause:
The multipart path was normalized after selection, commit identity was sampled too late, temporary-file defaults leaked into published files, pagination treated bottom padding as content, react-markdown applied its default data-URL filter, and image encoding accumulated both blob and base64 representations.

Fix:
Preserve selected paths, stage payloads privately with normal final permissions, track the staged inode before publication, sanitize filesystem errors, plan slices through meaningful content, admit only strict raster base64 image URLs, and encode image parts one at a time.

Verification:
- go test ./... and go vet ./... in root and desktop
- go test -race ./... in desktop
- pnpm test, pnpm test:typecheck, pnpm typecheck, and pnpm build
- Windows amd64/arm64 desktop test compilation
- Chromium UI image/PDF export smoke tests
- macOS WKWebView multi-slice PNG/PDF artifact verification

* Fix merged theme test window mock typing

Problem:
The latest main-v2 theme test narrows window.go with an intersection, but the existing global declaration keeps App typed as the complete AppBindings interface, so test:typecheck rejects the one-method mock.

Root cause:
Intersecting with typeof window cannot replace an already-declared nested property type.

Fix:
Cast through unknown for this isolated test mock so its intentionally partial bridge shape is checked against the local test contract.

Verification:
- pnpm test:typecheck
- pnpm exec tsx src/__tests__/theme-pack.test.ts
- pnpm exec tsx src/__tests__/session-export.test.ts
2026-07-19 21:22:33 +08:00
yuanchengluandSivanCola d50a9888f3 fix(desktop): backport Linux WebKit startup signal repair / 移植 Linux WebKit 启动信号修复 (#6655)
* fix(desktop): add WebKit2GTK 2.50 SIGUSR1 signal workaround for Linux crash

WebKit2GTK 2.50.x changed internal GObject type registration in ways that
interact badly with the Go runtime's SIGUSR1 signal handler, causing a
SIGABRT inside webkit_user_content_manager_new() on certain Linux
distributions (Deepin 23, Arch). The crash produces no useful error
output — just a white window that exits with code 1.

Root cause: Go's runtime uses SIGUSR1 (signal 10) for thread management,
and JavaScriptCore also uses SIGUSR1 for conservative GC stack scanning.
When Go registers its handler first, WebKit's attempt to set its own is
overridden, corrupting the GObject type system during webview setup.

This commit adds two workarounds applied via package init() before Wails
initialises the webview:

1. JSC_SIGNAL_FOR_GC=12 — tells JavaScriptCore to use SIGUSR2 instead of
   SIGUSR1, eliminating the signal conflict at the source.

2. WEBKIT_DISABLE_DMABUF_RENDERER=1 on NVIDIA GPUs — the DMA-BUF
   compositing path in WebKit2GTK 2.50.x is unstable with NVIDIA's
   proprietary driver; disabling it falls back to the stable software
   OpenGL path.

Also bumps the Wails dependency from v2.12.0 to v2.13.0 for additional
Linux/WebKit compatibility fixes from the upstream.

Tested on: Deepin 23, WebKit2GTK 2.50.4, Go 1.26.5, NVIDIA GT 1030
(driver 580.119.02), X11 session.

Cache-impact: none
Cache-guard: none (desktop/init only, no prompt/tool prefix changes)
System-prompt-review: none

* fix(desktop): keep WebKit workaround Linux-only

Problem:
The PR coupled a Linux WebKit compatibility workaround to a cross-platform Wails v2.13.0 upgrade with placeholder checksums, and the new Linux file redeclared an existing NVIDIA helper.

Root cause:
The dependency bump expanded the change beyond the affected platform, while the compatibility file duplicated platform support already owned by nvidia_wayland_linux.go.

Fix:
Restore Wails v2.12.0 and its verified checksums, reuse the existing Linux NVIDIA detector, and format the compatibility file.

Verification:
- go mod tidy
- go build ./...
- go test ./...
- git diff --check

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

* fix(desktop): backport Linux WebKit signal repair

Problem:
Wails v2.12 repairs Linux signal handlers only once, before JavaScriptCore may lazily replace them, so affected WebKitGTK applications can still abort during startup.

Root cause:
JavaScriptCore installs handlers after the one-shot repair and can drop the SA_ONSTACK flag required by the Go runtime. Moving JSC GC to SIGUSR2 does not restore those flags and is not the upstream fix.

Fix:
Backport the final Wails v2.13 Linux strategy: reapply SA_ONSTACK every 50 ms for a bounded five-second startup window and once again at DomReady, while leaving SIGUSR1 under JavaScriptCore ownership. Keep Wails v2.12 and retain the NVIDIA DMA-BUF workaround. Non-Linux hooks are no-ops.

Upstream: wailsapp/wails#5507, wailsapp/wails#5530

Verification:
- go test ./... (desktop)
- go build ./... (desktop)
- go vet ./... (desktop)
- go test ./... (repository root)
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build ./... (desktop)
- GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build ./... (desktop)
- git diff --check

* Scope Linux WebKit renderer fallback to safe mode

Problem:
All Linux NVIDIA launches inherited WEBKIT_DISABLE_DMABUF_RENDERER, disabling accelerated backing store, while the signal-repair comments implied coverage of failures before the GTK main loop.

Root cause:
The renderer fallback ran from package initialization without recovery gating. The backported GLib timer only dispatches after GTK enters its main loop.

Fix:
Apply the DMA-BUF fallback only to Safe Mode NVIDIA launches before Wails creates WebKit, preserve preconfigured environment values, keep other platforms as no-ops, and document the signal fix boundary. Add focused policy and platform tests.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop && go build ./...
- cd desktop && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build ./...
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-19 20:56:52 +08:00
SivanCola 00af3a0366 Fix LongCat-2.0 context window / 修正 LongCat-2.0 上下文窗口 (#6690)
* Fix LongCat context window presets

Problem:
LongCat-2.0 presets treated the 131,072-token maximum output as the full context window, causing Reasonix to compact conversations far too early.

Root cause:
The preset copied the API max_tokens limit instead of the model context_length metadata.

Fix:
Use the official 1,048,576-token context length for both LongCat transports and migrate only untouched official preset entries that still carry the legacy value. Preserve custom providers and user-edited context windows.

Verification:
- ./scripts/cache-guard.sh
- go test ./internal/config
- go test ./...

* Narrow LongCat preset migration

Problem:
The legacy-context migration could rewrite a customized LongCat preset that retained the old provider-wide value but exposed a different or additional model.

Root cause:
The migration matched only preset identity, kind, host, and context value; it did not verify the original endpoint and LongCat-2.0-only catalog.

Fix:
Require the exact normalized endpoint, original single-model catalog, and explicit default model. Add regression coverage for customized catalogs, endpoints, and defaults, and keep load coverage independent of implicit persistence.

Verification:
- ./scripts/cache-guard.sh
- go test ./internal/config
- go test ./...
- go vet ./...
2026-07-19 12:56:08 +00:00
HaoyueQinandSivanCola a144e55e41 feat(desktop): add pane opacity control and fix base style revert on custom theme save / 新增面板透明度控制,修复自定义主题保存后基础风格回退 (#6645)
* feat(desktop): add pane opacity control and fix base style revert on custom theme save

New feature — per-scene pane opacity (面板透明度):

- ThemePackBackground / ThemePackSceneBackground gain a paneOpacity field
  (0-1) that controls how transparent the shell panels render when a theme
  pack is active. Home defaults to 0.50, task defaults to 0.68.
- Independent sliders in ThemeGallery editor for both scenes.
- applyBackgroundCSSVars pre-computes CSS variables (--theme-pane-alpha,
  --theme-pane-shell-pct, --theme-pane-task-*) for color-mix rules.
- CSS rules under :root[data-theme-pack] apply pane opacity to sidebar,
  topicbar, dock, statusbar, chat-pane, composer-card, and transcript
  with cascading opacity tiers (shell < card < inner).
- ThemePreviewSurface preview cards now reflect pane opacity.
- Task background opacity caps relaxed from 0.45 to 1.0 per user demand.
- All 8 official mock themes include paneOpacity: 0.50.
- Schema and docs (EN/ZH) updated with paneOpacity field.

Bug fix — base style revert on Save & apply:

When ThemeGallery.saveEditor(activate=true) called activateThemePack(),
the downstream onExperienceChange callback passed exp.baseStyle (the
config-file appearance, e.g. graphite) to the parent's onThemeStyle.
The parent then called applyTheme(theme, OLD_style) which overwrote
the pack's baseStyle (e.g. refined) that applyThemePack had just set
on the DOM.

Fixes:
- AppearanceOverview.onExperienceChange now syncs exp.effectiveStyle
  instead of exp.baseStyle, and no longer calls onTheme (which reads
  stale themeStyle from its closure).
- SettingsPanel.onThemeStyle now reads the current theme from the DOM
  via getTheme() instead of the React closure theme state.
- ThemeGallery.saveEditor moves cancelThemePreview() to only the
  non-activate branch; the activate path must not restore the preview
  snapshot after the new pack is applied.
- themeExperience.activateThemePack calls clearPreviewSnapshotOnly()
  first so endPreviewIfAny() cannot overwrite the newly activated pack.
- Layout transparency CSS selectors gain .app:not(.app--creation) to
  exclude the creation-mode surface.

Test contract:
- TestTaskOpacityCapped renamed to TestOpacityClamped, now validates
  the new 0-1 clamping and PaneOpacity defaults for both scenes.
- 'layout background transparent' contract test updated to a single
  flexible regex that tolerates intermediate selectors.
- i18n: taskBackgroundHint adjusted (removed stale 45% cap reference);
  new paneOpacity label in EN/ZH/ZH-TW.

* fix(desktop): preserve theme pane transparency state

Problem:
- Explicit 0% pane opacity was replaced by legacy defaults.
- Token-only theme packs received background transparency rules.
- Failed theme activation discarded the reversible preview snapshot.

Root cause:
- A scalar JSON field could not distinguish omission from an explicit zero.
- Extended pane selectors were scoped to any theme pack instead of packs with backgrounds.
- The preview snapshot was cleared before persistence succeeded.

Fix:
- Represent pane opacity as an optional persisted value and preserve explicit zero through normalization and JSON round trips.
- Guard background and pane transparency CSS with data-theme-has-bg.
- Commit preview state only after successful activation and add focused regressions.

Verification:
- cd desktop && go test ./...
- cd desktop/frontend && pnpm run test
- cd desktop/frontend && pnpm run build
- Browser DOM/CSS checks for token-only and background theme previews

* fix(desktop): separate theme base state from pack effects

Problem:
- Applying a theme pack mirrored its effective style into the configured base appearance, so direct theme reset restored the pack style.
- Pane opacity previews changed while live card surfaces remained fixed at 88%.

Root cause:
- The gallery callback reused the configured-style owner callback for a temporary effective style.
- Card-tier CSS variables were computed but not consumed, and preview offsets differed from live formulas.

Fix:
- Synchronize configured base style only when no active pack owns the effective style.
- Consume scene-specific card opacity variables in live surfaces and use the same formulas in previews.
- Remove unused pane variables and add regressions for direct reset and opacity consumers.

Verification:
- cd desktop/frontend && pnpm run test
- cd desktop/frontend && pnpm run build
- cd desktop && go test ./...
- Browser: Sage/Slate reset restored Graphite; live and preview card opacity matched scene variables

* fix(desktop): preserve theme preview lifecycle

Problem:
- Save and apply closed the theme editor before activation completed, allowing unmount cleanup to restore the old preview snapshot.
- Base and token-only gallery previews received background pane transparency that live surfaces do not apply.

Root cause:
- Editor teardown occurred before activateThemePack committed the preview.
- Preview opacity variables were derived without checking whether the pack had a background.

Fix:
- Activate or cancel the preview before closing the editor and reloading the gallery.
- Keep pane previews opaque when no theme background is present.
- Add regressions for lifecycle ordering and preview opacity.

Verification:
- cd desktop/frontend && pnpm run test
- cd desktop/frontend && pnpm run build
- cd desktop && go test ./...
- Browser preview check: base previews stayed opaque while background previews retained configured alpha

* fix(desktop): keep theme activation and previews consistent

Problem:
- Save and apply persisted activation twice, so a later activation failure could split persistent state from the reversible preview.
- Browser official-theme mocks used 50% pane opacity while embedded manifests fell back to legacy defaults.
- Legacy task scene payloads without pane opacity did not inherit the home setting.

Root cause:
- ThemeGallery delegated activation to SaveThemePack and then called the experience activation controller again.
- Official manifests omitted the new field, and the task fallback tested object presence instead of field presence.

Fix:
- Make the experience controller the sole owner of active-theme pointer updates after saving.
- Declare 50% pane opacity in every official manifest and verify Wails/browser parity.
- Use nullish field fallback for legacy task scenes and add regression coverage.

Verification:
- pnpm exec tsx src/__tests__/theme-pack.test.ts (230 passed)
- pnpm run typecheck
- pnpm run check:css
- pnpm run test
- pnpm run build
- go test ./...

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-19 20:46:58 +08:00
司空祎宸andSivanCola a46fc6f47a fix(desktop): 桌面客户端最大化窗口时光标卡死在 resize 形状prevent cursor stuck at resize shape when window is max… (#6610)
* fix(desktop): prevent cursor stuck at resize shape when window is maximized

When the Wails frameless window is restored maximized, the 6px edge-detection
zone in useWailsResizeFix coincides with the physical screen edges. Moving
the mouse to the top/right/top-right corner sets style.cursor to a resize
value that WebView2 never reliably reverts via style.cursor = "", leaving
the cursor stuck in resize shape until the app is restarted.

The fix adds a synchronous check at the top of the mousemove handler:
when outerDimensions ≈ screen.availDimensions (within 5px tolerance) the
window is maximised, so edge detection is skipped entirely and any stale
resize edge is cleared via removeProperty("cursor").

Fixes wailsapp/wails#1503 (same bug, project-specific manifestation).

* fix(desktop): use native maximized state for resize cursor

Problem:
Maximized Windows desktop windows could retain a resize cursor, while near-full normal windows could be misclassified as maximized by the size heuristic.

Root cause:
Cursor recovery was gated on Wails resizeEdge state, and maximization was inferred from outer and screen dimensions instead of the native window state.

Fix:
Share the native maximized state with the Windows controls, normalize stale resize cursors, preserve the remembered default cursor, and skip edge detection only for confirmed maximized windows. Add focused startup, transition, and near-full-size regression coverage.

Verification:
- pnpm exec tsx src/__tests__/wails-resize-fix.test.tsx
- pnpm exec tsx src/__tests__/app-chrome-tabs.test.ts
- pnpm run typecheck
- pnpm test
- pnpm run build
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-19 20:03:43 +08:00
4c1702ca46 feat(desktop): 聊天区域的宽度修改add configurable conversation width (standard 960px / full 90%) (#6590)
* feat: add configurable conversation width (standard 960px / full 90%)

* fix: correct render.go conversation_width syntax (t+&amp;+literal newline)

* fix: gofmt -w desktop/settings_app.go

* Fix conversation width behavior and persistence

Problem:
- Full width could become narrower than standard in common window sizes and compounded inside nested transcript containers.
- The Appearance panel could display stale localStorage state instead of the persisted desktop setting.

Fix:
- Apply a 960px floor to full width and keep nested transcript content at 100% of its outer container.
- Treat localStorage as an early-paint cache, then reconcile from normalized desktop settings.
- Centralize config normalization and add frontend, config, render, and desktop bridge regressions.

Verification:
- pnpm test:all
- pnpm build
- go vet ./... and go test ./... in the root module
- go vet ./..., go build ./..., and go test ./... in the desktop module

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

---------

Co-authored-by: SauronSkywalker <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-19 19:44:30 +08:00
ffff2004andSivanCola 4411560530 feat(cli): support middle-click paste / 支持 CLI 中键粘贴 (#6589)
* feat(cli): support PRIMARY selection paste on middle-click

Add pastePrimary() that reads from the X11/Wayland PRIMARY selection
(via wl-paste --primary, xclip -selection primary -o, or xsel
--output --primary) and wire it to the MouseMiddle event in the TUI.

Previously, with mouse capture enabled, middle-click was consumed but
not handled, silently breaking the terminal convention of PRIMARY paste.
Now it works correctly: PRIMARY content is pasted into the composer, and
an empty PRIMARY is a silent no-op.

* fix(cli): paste tmux buffer on middle click

* fix(cli): harden middle-click paste handling

Problem:
Middle-click paste could expose credential-store variables to clipboard helpers, mutate a hidden composer, read PRIMARY on an SSH host, and alter selected text.

Root cause:
The new subprocesses inherited the full process environment, while the mouse and PRIMARY paths bypassed existing ownership, remote-session, and byte-preservation contracts.

Fix:
Filter every paste helper through secrets.ProcessEnv, respect tmux/SSH/composer boundaries, request text explicitly, preserve selection bytes, and add focused regression coverage.

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

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-19 11:41:00 +00:00
ikelvingoandSivanCola 1e1ba41d40 agent/save: make session filenames Windows-safe / 修复 Windows 会话文件名兼容性 (#6684)
* 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: :<>"|?*

* test(agent): cover Windows-safe session filenames

Problem:
The Windows filename fix had no regression covering a real colon-bearing model label or persistence through the generated path.

Fix:
Add a focused test for the curated nemotron-3-nano:30b model shape, cover the newly sanitized punctuation, and save plus stat a session through the real persistence path.

Compatibility:
The test exercises future filename derivation only. Existing session files and safe model labels remain unchanged.

Verification:
- go test ./internal/agent -run TestNewSessionPath -count=1
- go test ./internal/agent ./internal/control ./internal/cli ./internal/boot
- cd desktop && go test ./...
- git diff --check

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-19 19:31:25 +08: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
SivanCola 26aa92cb2d Harden crash diagnostics and recurring runtime failures / 强化崩溃诊断并修复高频运行时故障 (#6678)
* fix: harden crash diagnostics and runtime failures

Problem:
Crash telemetry mixed actionable release failures with browser notices, development reports, and isolated timer spikes. Several recurring desktop crashes remained in virtualized React lists, Mermaid pan/zoom, legacy WebKit, and concurrent secret redaction.

Root cause:
Virtualizer measurement updates could re-enter React commits, svg-pan-zoom inverted singular matrices before layout stabilized, react-markdown assumed Object.hasOwn, and the key/value redactor relied on a complex regexp replacement path. Opaque Script errors also lacked safe grouping context.

Fix:
Separate diagnostic lanes and effective severities, preserve legacy fingerprints while adding optional safe hints, require sustained or corroborated lag, use direct DOM virtualizer updates, guard Mermaid layout synchronization, polyfill Object.hasOwn, and replace the backtracking redactor with a linear scanner.

Verification:
go test ./...
go vet ./...
go test -race ./internal/secrets
(cd desktop && go test ./...)
(cd desktop/frontend && pnpm test:all && pnpm build)
(cd workers/crash-report && npm test -- --run && npm run typecheck)

* fix: isolate development crash groups

Problem:
Development and stable reports shared one fingerprint, so a low-severity development observation could hide an actionable release crash.

Root cause:
The fingerprint omitted the report environment while the group upsert persisted low severity and dashboard classification trusted the latest version/channel.

Fix:
Keep stable hashes unchanged, namespace development fingerprints with dev:, preserve dashboard routes for both formats, and only reclassify legacy groups when both ends of their history are development builds.

Verification:
- npm test -- --run
- npm run typecheck
- npm exec -- wrangler deploy --dry-run --outdir /tmp/reasonix-pr-6678-worker-dryrun
- git diff --check

* fix: keep ambiguous legacy crash groups actionable

Problem:
Legacy crash groups could contain a stable observation between development reports and still be downgraded as development-only.

Root cause:
The dashboard inferred complete group provenance from first/last version metadata even though sample retention can prune middle release reports.

Fix:
Treat only the explicit dev: fingerprint namespace as proof of a development-only group, keep legacy hashes actionable, and cover dev-release-dev history in regression tests.

Verification:
- npm test -- --run
- npm run typecheck
- npm exec -- wrangler deploy --dry-run --outdir /tmp/reasonix-pr-6678-worker-dryrun
- git diff --check
2026-07-19 17:06:46 +08:00
SivanColaandHaoyueQin c77af74c96 feat(config): support per-model context windows (#6677)
Problem: Shared provider endpoints can expose models with different context limits, but Reasonix only allowed one provider-wide context_window value.

Approach: Add an optional model_overrides.<model>.context_window, preserve existing capability overrides, expose the setting in desktop UI with token guidance and a low-value warning, and document the behavior.

Compatibility: Missing or zero model overrides inherit the provider value. The desktop JSON field is optional, and older TOML readers ignore the additive nested key.

Cache impact: No provider-visible prompt or schema bytes change. Explicitly smaller values may compact earlier; the UI warns below 16384 tokens and unset values preserve existing behavior.

Verification: ./scripts/cache-guard.sh; go vet ./...; go test ./...; cd desktop && go test ./...; frontend typecheck, CSS checks, and provider model tests.

Co-authored-by: HaoyueQin <[email protected]>
2026-07-19 16:15:15 +08:00
SivanCola dd929bf2df Bound session save lock waits / 限制会话保存锁等待时间 (#6667)
Problem: A competing process holding the compatibility session save lock
wedged desktop navigation and true-quit shutdown indefinitely, kept the
session lease and WebView alive, and a timed-out final snapshot could
discard transcript changes that existed only in memory (#6637, Bug 3).

Root cause: flock/LockFileEx used blocking acquisition with no deadline;
the shutdown path treated a held lock like any other snapshot failure;
and during the two-phase recovery handoff a concurrent tab-layout save
could overwrite the recovery anchor with the controller's stale path.

Fix: Retry non-blocking lock acquisition for a bounded five-second window
on Unix and Windows and keep the in-memory transcript on failure; on a
true shutdown timeout persist the transcript to a process-specific
recovery branch that never re-acquires the stalled original lock, reusing
the standard session/event-log/branch-meta formats and recovery handoff;
make the lease-backed tab path authoritative during the handoff via a
lock-free lease-key mirror so layout saves never wait on lease file
operations.

Verification: go test -race ./internal/agent ./internal/control; cd
desktop && go test -race ./...; go vet ./... (root and desktop modules);
trial merge with origin/main-v2 clean; scripts/check-cache-impact.sh
reports no cache-sensitive files changed; full CI green including race,
desktop-windows, cache-impact, and CodeQL.
2026-07-19 14:45:12 +08:00
2b43e0f8c6 Preserve cancelled turn display history / 保留中断轮次的可见历史 (#6666)
Problem:
Stopping a Desktop turn discarded the streamed assistant/reasoning/tool output
after a reload or tab switch, and a cancelled plan-mode prompt could carry
stale mode instructions or lose the current user message entirely.

Root cause:
Streaming cards lived only in frontend memory; cancellation correctly removed
the incomplete tail from the provider transcript but left no durable display
buffer, and display state was owned by transient tab wrappers.

Fix:
Mark user-cancelled TurnDone events, preserve a prefix-free current user
fallback with image attachments, buffer display output in runtime-owned shared
state across detach/reattach, persist cancelled executor output in the
display-only planner sidecar guarded by a bounded cross-process file lock,
retry failed writes in order, remove or prune sidecar entries on permanent
deletion and orphan cleanup, and show a localized notice explaining that
partial output is not model context.

Verification:
Root and desktop Go suites, focused -race tests, deterministic cross-process
subprocess regression, allocation regression, Windows cross-compilation,
frontend typecheck and controller meta tests, and scripts/cache-guard.sh pass.

Fixes #6260
Fixes #6297
Fixes #6644

Co-authored-by: GTC2080 <[email protected]>
Co-authored-by: SauronSkywalker <[email protected]>
2026-07-19 14:40:56 +08:00
SivanCola 2edfdc1b71 Merge pull request #6668 from SivanCola/fix/windows-plugin-hook-command
Fix quoted Windows batch plugin hooks / 修复 Windows 插件批处理 Hook 引号兼容
2026-07-19 13:05:25 +08:00
SivanCola d6d1abb419 Merge pull request #6672 from SivanCola/fix/test-cache-path-isolation
test(config): isolate platform cache roots / 隔离平台缓存根目录
2026-07-19 13:03:14 +08:00
SivanCola 9c96fd7a51 test(config): isolate platform cache roots
Problem:
The shared test user-state guard left inherited platform cache roots untouched, so tests could still reach caller-owned directories on Linux and Windows.

Root cause:
The guard redirected home and config roots but did not cover every environment variable consulted by os.UserCacheDir.

Fix:
Redirect XDG cache and state roots plus Windows LocalAppData into the disposable home. Verify redirection, restoration, and the actual config paths that depend on the OS cache root.

Verification:
- go test ./internal/testenv ./internal/config -count=1
- go test -race ./internal/testenv ./internal/config -count=1
- go test -p=1 ./... -count=1
- go vet ./...
- ./scripts/check-cache-impact.sh
2026-07-19 12:45:43 +08:00
SivanCola f94de5826a Merge pull request #6670 from SivanCola/docs/revert-clipboard-credit-docs
Revert #6664 documentation credit / 撤回 #6664 文档致谢
2026-07-19 12:37:18 +08:00
SivanCola 5585894b3a Merge pull request #6639 from ffff2004/fix/test-config-isolation
test(config): isolate test user state / 隔离测试用户状态
2026-07-19 12:14:03 +08:00
SivanCola 1a8b6f827d fix(hooks): preserve Windows batch argument semantics
Problem: The quoted Windows batch fix re-serialized every parsed argument with double quotes, changing raw batch parameters such as %1 for hooks that were already valid.

Root cause: The compatibility path matched every simple batch shell command and discarded the original shell-form argument text before constructing the cmd.exe command line.

Fix: Limit shell-form handling to the broken leading-quoted executable shape, preserve its argument tail verbatim, quote argv-form arguments only when required, and make the Windows runtime test assert raw %1 behavior.

Verification: go test ./internal/hook -count=1; go test -race ./internal/hook -count=1; go vet ./internal/hook ./internal/pluginpkg ./internal/config; GOOS=windows GOARCH=amd64 go test -c ./internal/hook; go test -p 4 ./...; cd desktop && go test -p 4 ./...; ./scripts/check-cache-impact.sh.
2026-07-19 12:08:45 +08:00
SivanCola bf612ec7ee test(control): snapshot session during steer polling
Problem:
The full race job found the steer fallback test reading Session.Messages while
the background turn appended to the same slice.

Root cause:
The polling helper bypassed the concurrency-safe Session.Snapshot API.

Fix:
Read a locked session snapshot before inspecting user messages.

Verification:
- go test -race ./internal/control -run TestSteer -count=20
- go test -race ./internal/control -count=1
2026-07-19 12:05:26 +08:00
SivanCola ec3691d01d docs: remove clipboard attribution copy
Context:
- PR #6664 recorded the #6560 author as a repository co-author and also added permanent attribution text to the changelog and bilingual READMEs.

Change:
- Restore CHANGELOG.md, README.md, and README.zh-CN.md to their exact pre-#6664 content.
- Keep the merged #6664 commit and its Co-authored-by attribution in Git history.

Verification:
- git diff --check
- Tree comparison against the first parent of #6664
- ./scripts/check-cache-impact.sh
2026-07-19 12:03:51 +08:00
SivanColaandLemon Hall df1578bc1c test(config): isolate all user state writes
Problem:
Tests outside the original CLI path could still inherit REASONIX_HOME,
REASONIX_STATE_HOME, or REASONIX_CACHE_HOME and mutate caller-owned state.

Root cause:
Isolation was duplicated per package and Windows/default path precedence allowed
inherited overrides to escape temporary homes.

Fix:
Add a shared process-level test environment guard, apply it to state-writing
packages, preserve helper subprocess behavior, and adopt Windows path
containment coverage from #6518.

Verification:
- go test -p=1 ./... -count=1 with external home/state/cache sentinels
- go test -race ./internal/testenv ./internal/config ./internal/cli -count=1
- go vet ./...
- ./scripts/check-cache-impact.sh

Co-authored-by: Lemon Hall <[email protected]>
2026-07-19 11:53:28 +08:00
SivanCola d0296b1232 Merge pull request #6664 from SivanCola/docs/credit-cli-clipboard-contributor
Credit CLI clipboard co-contributor / 致谢 CLI 剪贴板共同贡献者
2026-07-19 11:53:08 +08:00
SivanCola b98903560f fix(hooks): run quoted Windows batch plugin commands
Problem: Windows plugin hooks such as superpowers use a quoted run-hook.cmd path. Passing that command through Go's default cmd.exe argument encoding leaves the quotes in the command name, so SessionStart fails.

Root cause: Go's Windows argument encoder follows CommandLineToArgvW, while cmd.exe and batch files use different quote stripping rules.

Fix: Recognize simple .cmd/.bat hook invocations in both shell and argv forms, normalize the executable path separators, and provide an exact cmd.exe /d /s /c command line through SysProcAttr. Compound and non-batch shell contracts remain unchanged.

Verification: go test -p 4 ./...; cd desktop && go test -p 4 ./...; go test -race ./internal/hook; go vet ./internal/hook ./internal/pluginpkg ./internal/config; GOOS=windows GOARCH=amd64 go test -c ./internal/hook.
2026-07-19 11:45:10 +08:00
SivanColaandAndrei Kiparuk 8395ab934b docs: credit CLI clipboard co-contributor
Problem:
- The contributor behind #6560 helped identify and solve the CLI clipboard compatibility gap, but the integrated #6632 commit did not record that co-contribution.

Root cause:
- The final implementation was merged independently without a contributor trailer or a permanent acknowledgment next to the shipped fix.

Fix:
- Credit Andrei Kiparuk in the changelog and bilingual README acknowledgments.
- Add a public Co-authored-by trailer so GitHub records the contribution after merge.

Verification:
- git diff --check
- ./scripts/check-cache-impact.sh

Co-authored-by: Andrei Kiparuk <[email protected]>
2026-07-19 11:40:51 +08:00
ffff2004 bb9c4aa547 test(config): isolate config writes from user home 2026-07-18 23:28:02 +08:00
SivanCola 2335d0df9e Add profile-aware parallel subagent fleets / 新增可配置并行子智能体 Fleet (#6636)
* feat(subagents): add profile-aware parallel fleets

Problem:
Custom subagent profiles could only be run serially through skill execution, making multi-document workloads unnecessarily slow and leaving concurrent writers without one session-wide safety boundary.

Root cause:
Task execution had no runtime profile selector or shared fleet scheduler, and write coordination was split across entry points without atomic parent reservations or enforceable path claims.

Fix:
- add profile-aware task and fleet execution with session-wide total/writer concurrency limits
- normalize and enforce write path claims, including sandboxed Bash roots and parent write reservations
- make background tasks queue asynchronously and keep parallel_tasks on the shared scheduler
- expose concurrency and read-only profile controls in Desktop and document the contracts

Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/agent ./internal/boot ./internal/config ./internal/skill
- cd desktop/frontend && pnpm typecheck && pnpm test:typecheck && pnpm test
- ./scripts/cache-guard.sh

* Tighten subagent write and tool boundaries

Problem:
Path-bound Bash retained sandbox cache and temporary-directory write lanes, wildcard tool allowlists could reject valid narrowed calls, and stale scheduler code blocked lint.

Root cause:
Bash root rebinding did not enable minimal-write confinement, tool lists were intersected before live-registry pattern expansion, and obsolete control flow remained after scheduler unification.

Fix:
Restrict rebound Bash to claim roots, expand both allowlists before intersection, remove stale scheduler parameters and helpers, and add focused execution regressions.

Verification:
- golangci-lint v2.12.2: 0 issues
- go test ./...
- go test -race ./internal/agent ./internal/tool/builtin ./internal/boot ./internal/config ./internal/skill
- cd desktop && go test ./...
- ./scripts/cache-guard.sh

* Preserve fleet cancellation results

Problem:
Cancelled fleet writers could be reported as skipped after they had started, and preflight conflicts could cite the wrong task numbers when read-only items were interleaved.

Root cause:
The cancellation path stopped draining terminal worker results after waiting, while write claims were compacted into a writer-only slice before indexed validation.

Fix:
Drain every started worker result after cancellation, reserve skipped for tasks that never started, and retain one claim slot per original fleet item. Add deterministic cancellation and mixed-task indexing regressions.

Verification:
- go test -race ./internal/agent -run '^TestFleet' -count=1
- go test ./internal/agent
- go test ./...
- golangci-lint v2.12.2: 0 issues
- ./scripts/cache-guard.sh
- git diff --check
2026-07-18 22:18:44 +08:00
SivanCola 5eac2d7978 Add regional typography controls / 新增分区域字体设置 (#6634)
* feat(desktop): add regional typography controls

Problem:
Users can only change one global font family and text scale, so conversation, composer, code, metadata, and navigation cannot be tuned independently. Assistant response styles can also override the selected conversation typography.

Root cause:
The frontend exposes a single global typography layer, while higher-specificity theme rules hardcode assistant response font properties.

Fix:
Add persisted, backward-compatible typography preferences and a detailed settings view for five semantic regions. Route each region through CSS custom properties, localize the UI, and make assistant responses inherit the conversation font and size.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/typography-preferences.test.ts
- pnpm --dir desktop/frontend exec tsx src/__tests__/typography-overflow-contract.test.ts
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend build
- Browser computed styles confirm matching user and assistant typography

* fix(desktop): honor regional typography settings

Problem:
Regional typography settings could be ignored by assistant replies, code/tool content, and metadata. The theme restore copy also implied a broader reset than it performs.

Root cause:
Root-scoped typography tokens were computed before descendant region scales, and later theme selectors overrode regional font values. Custom font input could also diverge from its normalized persisted value, while restore labels did not name the Graphite-only scope.

Fix:
Expose exact per-region size properties, recompute semantic typography tokens at each region, route late selectors through regional variables, normalize custom font input before state updates, and clarify/localize the Graphite restore copy while preserving detailed typography.

Verification:
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
- Browser checks confirmed user and assistant replies render at 24px and remain 24px after restoring the Graphite appearance.

* fix(desktop): align typography previews

Problem:
Supporting-text customization changed the effective default from 12px to 11px, and code/metadata previews did not match their applied sizes.

Root cause:
The metadata region used an 11px scaling base while the existing status token uses 12px. The preview also multiplied every region by a hardcoded 14px base.

Fix:
Align metadata with the 12px supporting-text base and give each preview region an exact size variable with the correct global fallback.

Verification:
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
- Browser computed-style checks confirmed code and metadata previews match their configured sizes and metadata stays 12px when leaving follow-global.

* fix(desktop): scale diff text with global size

Problem:
Diff text stayed at 12.5px while the code and tools region followed a non-default global text size.

Root cause:
The diff fallback bypassed the global font scale and only read the exact regional override.

Fix:
Scale the legacy 12.5px diff base by the global text-size token until the code region is customized, and lock the behavior with a CSS contract test.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/typography-overflow-contract.test.ts
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend build
2026-07-18 22:18:20 +08:00
SivanCola 40ef98de92 Fix CLI terminal interactions and transcript hierarchy / 修复 CLI 终端交互与消息层级 (#6632)
* fix(cli): restore terminal interactions and transcript hierarchy

Problem:
- Captured mouse mode could copy selections but could not right-click paste clipboard text.
- Composer drag selections were visible but did not reliably reach the system clipboard.
- Non-Git workspaces reserved or misaligned an empty footer data band.
- Assistant answers lacked a distinct identity and spacing from reasoning and turn receipts.

Root cause:
- Mouse handling had no text-only clipboard read path, composer selection release stopped at local state, and footer/transcript rendering assumed fixed visual bands.

Fix:
- Route right-click clipboard text through the canonical paste event and auto-copy composer drag selections.
- Make optional footer telemetry compact and left-aligned outside Git repositories.
- Render assistant markdown with a Reasonix identity, stable indentation, replay support, and semantic spacing.
- Document the interaction changes and add focused regression coverage.

Verification:
- go test ./internal/cli
- go test ./...
- git diff --check

* fix(cli): clarify right-click paste over SSH

Problem: Captured right-click paste attempted to read the remote host clipboard during SSH sessions and the documentation implied it could access the user's local clipboard.

Root cause: The right-click path called the native clipboard reader without applying the remote-session boundary already used by clipboard copy.

Fix: Detect SSH sessions before reading clipboard text, preserve the composer, show a localized terminal-paste hint, and document local versus remote behavior.

Verification: go test ./internal/cli; go test ./...; go vet ./...; git diff --check.

* test(cli): isolate local clipboard cases from SSH

Problem: Four local clipboard interaction tests inherited SSH environment variables and failed when the suite ran on a remote development host.

Root cause: The tests stubbed native clipboard operations without explicitly selecting a local clipboard session.

Fix: Add a shared test helper that clears all supported SSH markers and use it in each local paste and drag-copy case.

Verification: Normal and simulated-SSH go test ./internal/cli -count=1; go test ./...; go vet ./...; git diff --check.
2026-07-18 05:11:47 +00:00
SivanCola 840cb6cad2 fix(site): restore registry publish button layout (#6630)
Problem:
The registry publish CTA rendered as a narrow vertical pill instead of a normal horizontal button.

Root cause:
The CTA reused the publish panel's reg-publish class and inherited the panel's asymmetric section padding.

Fix:
Give the toolbar CTA a dedicated class and add a source contract test that keeps the CTA and panel layout classes separate.

Verification:
- npm test (site): 21 passed
- npm run build (site): passed
- In-app browser: CTA renders at 82 x 45 px with 12 px 26 px padding
2026-07-18 02:50:42 +00:00
SivanCola e7a0f1163b fix(release): prevent skipped stable publishers (#6629)
Problem:
The stable orchestrator reported success after approval even though the CLI, npm, and Desktop publication jobs were skipped through a transitive skipped dependency. The existing recovery dispatch also could not use a workflow fix without moving the already-published tags.

Root cause:
Each reusable publisher joined an orchestrated guard with a deliberately skipped standalone gate. The cache guard used an explicit status condition, but its descendants did not, so GitHub propagated the skipped ancestor through the dependency chain. Recovery also checked out and executed control scripts from the old release candidate.

Fix:
Add explicit success conditions to every affected publication stage, keep recovery authorization and verification on protected main-v2 while building the immutable historical tag SHA, and reject non-ancestor or mismatched tag sets. Add a stable postflight that verifies required CLI/Desktop release assets and the exact npm latest version so a no-op release cannot stay green.

Verification:
- bash scripts/release-workflows.test.sh
- go run github.com/rhysd/actionlint/cmd/[email protected] -ignore 'label "windows-11-arm" is unknown' on all release workflows
- bash -n on all changed release scripts
- verify-stable-release-artifacts.sh against public v1.17.14 artifacts
- ./scripts/cache-guard.sh
- go test ./...
- git diff --check
2026-07-18 08:36:22 +08:00
SivanCola 315f6f3053 chore(release): prepare v1.17.15 notes
Release notes:
- add reviewed bilingual highlights, changes, upgrade notes, and risk notes
- ground every behavioral claim in merged pull requests from the v1.17.14 range
- include stable Desktop, CLI, MCP, agent, and provider release links

Verification:
- release catalog validation and rendering tests
- root and Desktop test, race, and vet suites
- frontend and site tests plus production builds
- cache guard and npm cross-platform dry run
- bounded live-provider tool, error-recovery, usage, and resume scenarios
- Linux, macOS, and Windows CI, CodeQL, and vulnerability checks
2026-07-18 07:52:33 +08:00
SivanCola 8a36da8db8 Site dark mode, cache-block motif & changelog format / 官网暗色模式、缓存方块母题与更新日志改版 (#6627)
* site: add dark mode, trim decorative FX, unify cache-block motif

Motivation:
- The marketing site shipped light-only, which mismatches the
  terminal-tool audience, and the hero stacked many decorative
  effects at once (cursor glow, magnetic buttons, headline
  typewriter, dot grid, 3D tilt, spotlight borders, marquee) with
  no single signature interaction.
- The cache-block visual from "How it works" is a product-native
  motif that was used only once.

Changes:
- Add a three-state theme switch (system/light/dark) shared by the
  marketing pages, community pages, and changelog: a pre-paint
  inline script reads localStorage "reasonix-theme", the shared
  theme.js module wires the toggle, keeps meta[theme-color] in
  sync, and follows OS theme changes while the preference is
  "system".
- Flip every surface to tokens: hardcoded #fff surfaces become
  var(--card), white color-mix tints mix into var(--bg)/var(--card),
  nav blur backgrounds derive from var(--bg); global.css and
  community.css gain full :root[data-theme="dark"] palettes.
- Remove cursor glow, magnetic buttons, and the h1 typewriter
  (JS, CSS, and the Base.astro div); the dot-grid and terminal-tilt
  rAF loops now run only while visible via IntersectionObserver.
- Reuse the cache-block motif: section dividers become a row of
  cache blocks with a solid center, the 404 page gets a "gone cold"
  block row, kickers/docs-kickers go monospace with a "$ " prefix,
  and the hero headline highlights its keyword as a mono cache
  block.
- Hide the header sign-in button below 640px so the theme switch
  fits the mobile nav.

Verification:
- cd site && npm test: 14/14 pass.
- cd site && npm run build: 19 pages built; dist inspected for
  [data-theme=dark] rules, theme-switch markup, and removed-FX
  leftovers (none found).

* site: scale changelog format to the release cadence

Motivation:
- The changelog layout was designed for monthly flagship releases,
  but Reasonix ships near-daily patches: every patch rendered a
  full-ceremony hero, empty Upgrade/Risk sections stayed on the
  page and in the TOC, the version rail listed every release flat,
  and /changelog/ duplicated the latest version page under two
  canonical URLs.

Changes:
- Render Guides/per-kind changes/Upgrade/Risk/Thanks sections only
  when they have content, and drop them from the TOC accordingly;
  section numbers are now sequential 01..N over the sections that
  actually render (no more mixed 01/U/R/heart badges).
- Releases whose title is just the version string (patch releases)
  get a compact hero: no duplicated version line, no action
  buttons, summary as a plain lede. Thematic titles keep the full
  hero.
- Group the version rail by minor (e.g. 1.17.x), showing the latest
  three patches per group; older patches sit behind a native
  <details> expander that auto-opens when it holds the current
  version.
- /changelog/ now canonicalizes (and og:url) to the latest
  version's /changelog/vX.Y.Z/ URL via an optional canonical prop
  on Base.astro.
- Align the changelog palette with the shared tokens: release
  blue/ink/backdrop alias to the global accent/ink/bg tokens and
  inherit their dark flip, keeping only --release-paper (the ledger
  identity) and the semantic orange/green as page-local tokens.

Verification:
- cd site && npm test: 14/14 pass.
- cd site && npm run build: /changelog/ canonical points to
  /changelog/v1.17.14/; v1.17.14 renders compact with no empty
  sections and gapless 01-06 numbering; v1.17.13 renders the full
  hero with guides/upgrade/risks/thanks present.

* site: lead the download tabs with the desktop app

Motivation:
- The hero already presents the desktop app as the primary install
  path, but the download section at the bottom defaulted to the npm
  tab, so the two install narratives disagreed.

Changes:
- Reorder the download tabs to Desktop / npm / Homebrew and make
  the desktop pane the default active one; pane markup is otherwise
  unchanged, and ?dl= deep links plus data-goto jumps still
  activate panes by name.

Verification:
- cd site && npm test: 14/14 pass.
- cd site && npm run build: 19 pages built; dist shows the desktop
  tab first and active by default.

* site: require exact version-string titles for the compact changelog hero

Problem:
- A thematic release whose title starts with the version string
  (e.g. "Reasonix 1.18.0 — Better agents") would be classified as a
  patch release and lose its full hero, including the download and
  GitHub actions.

Root cause:
- The classification regex /^reasonix\s+v?\d+\.\d+\.\d+/i had no
  end anchor, so any title merely STARTING with the version matched.

Fix:
- Extract isThematicTitle() into site/src/scripts/release-title.js:
  an exact, normalized comparison against "Reasonix <version>" and
  "Reasonix v<version>" (trimmed, case-insensitive, collapsed
  whitespace). Titles that only begin with the version stay
  thematic; edge cases (empty title/version) default to the full
  hero.
- Cover it with release-title.test.mjs, including the
  "Reasonix 1.18.0 — Better agents" regression case.

Verification:
- cd site && npm test: 19/19 pass.
- cd site && npm run build: 19 pages; v1.17.14 stays compact,
  v1.17.13 keeps the full hero.

* site: keep the header nav within 390px viewports

Problem:
- With the new theme switcher added, the header overflowed
  horizontally at 390px: the marketing nav measured ~448px and the
  install button was clipped by overflow-x: hidden; the community
  nav measured ~428px with the theme switch partially clipped and
  the account entry fully offscreen.

Root cause:
- The previous mitigation (hiding .nav-sign-in below 640px) only
  freed ~80px, not enough once brand text, language switch, theme
  switch, and install button all compete for 390px.

Fix:
- global.css ≤640px: logo-only brand (.nav .brand span hidden) and
  narrower theme-switch buttons (9px padding), bringing the nav to
  ~360px.
- community.css: new ≤640px block hiding .brand .ctag, tightening
  .nav-inner padding/gap, and narrowing theme-switch buttons,
  bringing the nav to ~315px.
- Add mobile-nav-contract.test.mjs asserting these contraction
  rules exist in the ≤640px blocks of both stylesheets (source-level
  contract; the repo has no browser layout test harness).

Verification:
- cd site && npm test: 19/19 pass.
- cd site && npm run build: 19 pages built.

* site: keep mobile headers within narrow viewports

Problem:
- The community header fit at 390px only while the async account slot was empty; the signed-out link widened it to 406px and was clipped.
- The marketing header still overflowed at the 320px reflow boundary.

Root cause:
- The responsive contract did not budget for the post-load account control or the narrower mobile breakpoint.

Fix:
- Give the community brand a stable accessible name, collapse it to the mark below 440px, and tighten language, theme, account, and container spacing.
- Add a 360px marketing breakpoint that preserves every header control at 320px.
- Extend the source contract tests to cover the async account slot and narrow layout rules.

Verification:
- cd site && npm test: 20/20 passed.
- cd site && npm run build: 19 pages built.
- Browser checks at 390px and 320px showed no horizontal overflow after the account link loaded; desktop brand labels remained visible.
- git diff --check passed.
2026-07-18 07:34:25 +08:00
SivanCola 3637d0f028 ci(release): consolidate GitHub approval and retain SignPath review / 合并 GitHub 审批并保留 SignPath 手工确认 (#6626)
* ci(release): orchestrate stable publishing behind one approval

Problem:
Stable CLI, npm, and Desktop tags started independent workflows, so one stable release required repeated GitHub environment approvals and separate coordination.

Root cause:
Each publisher owned its stable tag trigger and protected environment instead of sharing one validated release decision.

Fix:
Add a stable release orchestrator that validates the atomic three-tag set, requests one environment approval, and invokes guarded reusable publishers in parallel. Preserve standalone prerelease and recovery gates, harden version resolution, and add workflow contract coverage and release documentation.

Verification:
- actionlint v1.7.7 on release workflows and CI
- bash scripts/release-workflows.test.sh
- ./scripts/cache-guard.sh
- node --test scripts/release-notes.test.mjs
- go test ./...

* fix(release): bind publishers to approved commit

Problem:
The reusable publishers trusted a caller path on any ref, rebuilt mutable tags after approval, and the new actionlint gate failed on an unquoted keychain list.

Root cause:
The orchestrator passed tag names but not its approved commit, while child guards did not require a protected caller ref or matching workflow SHA. The macOS keychain command also relied on shell word splitting.

Fix:
Require protected caller context, propagate the approved CLI tag and SHA, check out the immutable commit, and revalidate remote tags before publication. Preserve keychain arguments with a Bash array and document the repository-write trust boundary.

Verification:
- bash scripts/release-workflows.test.sh
- go run github.com/rhysd/actionlint/cmd/[email protected] on all release workflows
- ./scripts/cache-guard.sh
- node --test scripts/release-notes.test.mjs
- go test ./...
- git diff --check

* fix(ci): remove ripgrep dependency from release tests

Problem:
The release workflow contract test passed locally but failed in GitHub Actions because the lint runner does not provide ripgrep.

Root cause:
The test script used rg only for simple regular-expression assertions even though CI does not install that optional tool.

Fix:
Use portable grep -E assertions throughout the release workflow contract test.

Verification:
- env PATH=/usr/bin:/bin:/usr/sbin:/sbin bash scripts/release-workflows.test.sh
- go run github.com/rhysd/actionlint/cmd/[email protected] on all release workflows
- git diff --check

* docs(release): retain manual SignPath confirmations

Context:
The release workflow now consolidates GitHub environment approval, while SignPath manual confirmations remain an intentional second-party control.

Change:
Clarify workflow comments and the release runbook so the one-approval claim applies only to GitHub. Document that the AMD64 and ARM64 signing requests can each require manual SignPath confirmation.

Verification:
- bash scripts/release-workflows.test.sh
- go run github.com/rhysd/actionlint/cmd/[email protected] on all release workflows
- git diff --check
2026-07-18 02:46:04 +08:00
d3cfa5c264 fix(agent): honour DeepSeek finish_reason=stop on reasoning-only final answer (#6618)
* fix(agent): honour DeepSeek finish_reason=stop on reasoning-only final answer

DeepSeek thinking mode routinely streams a long reasoning_content and then
finishes with finish_reason="stop" but an empty content block: the model has
signalled completion and its answer lives in the reasoning stream that was
already streamed to the user. The empty-final-answer retry only checked text
content (hasVisibleFinalAnswer), so it misclassified such a turn as "no
answer", overrode the stop signal, and forced another expensive thinking
round (the "still thinking after the task is done" symptom), up to 3 times
- or indefinitely when interleaved with tool calls, since emptyFinalBlocks
only resets on a tool call.

Guard the retry with reasoningOnlyFinishHonoured so a stop + non-empty
reasoning turn is accepted as a final answer instead of retried.

Backward-compatible: scriptedProvider emits no usage chunk, so usage is nil
and the new check returns false, preserving the current retry behaviour
(TestRunRetriesReasoningOnlyFinalAnswer, TestRunStopsAfterRepeatedEmptyFinalAnswers).

Co-Authored-By: Claude <[email protected]>

* fix(agent): scope reasoning-only finish accept to DeepSeek thinking mode

Problem: reasoningOnlyFinishHonoured accepted any reasoning-only
finish_reason="stop" turn from any provider, silently widening the
DeepSeek fix to every backend: local <think>-tag models (ollama/vLLM)
lost the empty-final retry safety net that often recovers a visible
answer on the second attempt, and a gateway that mislabels truncation
as "stop" could have a degenerate turn committed as the final answer.

Root cause: the guard keyed only on wire signals (finish reason plus
accumulated reasoning) with no provider scope, while the symptom being
fixed (#6617) is specific to the DeepSeek thinking protocol.

Fix: gate the accept on provider.RequiresToolCallReasoning — the
existing ToolCallReasoningPolicy capability that is true exactly when
the OpenAI-compat client runs DeepSeek thinking mode (explicit
reasoning_protocol="deepseek", or an official DeepSeek base URL, with
thinking not disabled). Every other provider keeps the unchanged
retry-then-error behaviour.

Verification: new TestRunRetriesReasoningOnlyStopWithoutDeepSeekPolicy
replays the accept test's exact chunk sequence without the capability
and asserts the retry still fires; TestRunAcceptsReasoningOnlyFinalWhenModelStopped
now runs under a DeepSeek-marked provider and still accepts in one
call. go test ./internal/agent/ ./internal/provider/openai/ — ok;
go vet ./internal/agent/ — clean; gofmt — clean.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: SivanCola <[email protected]>
2026-07-17 18:01:41 +00:00
SivanCola 7f00d2c260 feat(desktop): Redesign theme experience with official packs / 主题体验改造与官方主题 (#6614)
* feat(desktop): Add Theme Pack V1 engine with theme library / 新增 Theme Pack V1 引擎与主题库

Controlled, non-executable desktop skins: semantic color tokens, density and
corner recipes, and an optional local background image with focus and safe
area. Includes manifest validation, atomic import with replace confirmation,
content-addressed asset serving, live preview with cancel restore, density
consumers, transparent layout under backgrounds, contrast warnings, and
safe-mode fallback to Graphite. / 受控不可执行的桌面皮肤:语义色令牌、密度
与圆角配方、可选本地背景图(焦点与安全区)。包含清单校验、原子导入与覆盖
确认、内容寻址资源服务、可取消的实时预览、密度消费者、背景下布局透明化、
对比度警告与安全模式回退 Graphite。

* feat(desktop): Redesign theme experience with official packs / 主题体验改造与官方主题

Problem:
Appearance settings exposed two competing base-style pickers and applied
theme packs immediately on card click, while activeThemeId could store base
style ids. Official skins and a gallery/overview IA were still missing.

Root cause:
Theme Pack V1 layered packs on top of desktop.theme_style without a unified
state model or select-then-apply UX, so base styles, official themes, and
user packs competed for the same controls.

Fix:
- Bump desktop-theme-state to schema v2: activeThemeId is official/user only;
  migrate legacy base-style active ids into desktop.theme_style.
- Add GetThemeExperience, ActivateBaseStyle, DisableThemePack, and
  RestoreGraphiteAppearance; reject base ids in ActivateThemePack.
- Embed eight official themes with fixed order, asset routing, and budgets.
- Ship AppearanceOverview + ThemeGallery (select ≠ apply, temp preview,
  immersive detail) driven by themeExperience.ts.
- Document the IA and provenance; keep procedural art as placeholders.

Verification:
- cd desktop && go test ./...
- cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build
- wails generate module (bindings match, no drift)

* feat(desktop): complete the theme gallery and editor experience

Problem:
The theme gallery still had fragmented preview actions, placeholder flagship art, generic base-style previews, and a minimal custom-theme dialog that could not tune semantic colors or independent scenes.

Root cause:
Theme Pack V1 modeled one background and the gallery treated selection, preview, and persistence as separate interactions without a shared localized editor state.

Fix:
Upgrade the pack contract compatibly to V2 with independent home and workspace scenes, rebuild the gallery around immediate reversible preview and explicit apply, add localized semantic color and scene controls, replace the eight official artworks, and align base-style previews and actions with the settings UI.

Verification:
- cd desktop && go test ./...
- cd desktop/frontend && pnpm test:all
- cd desktop/frontend && pnpm typecheck
- cd desktop/frontend && pnpm check:css
- cd desktop/frontend && pnpm build
- Browser-verified Chinese and English labels, live preview, scene switching, and cancel restoration

* fix(desktop): satisfy the full-module formatting gate

Problem:
The desktop CI job stopped before tests because its repository-wide gofmt check reported main.go and theme_assets.go.

Root cause:
The earlier theme integration left one struct field with manual alignment and one extra trailing blank line; package tests compile these files but do not enforce gofmt byte formatting.

Fix:
Run gofmt on both reported files without changing runtime behavior.

Verification:
- cd desktop && gofmt -l .
- cd desktop && go test ./...

* fix(desktop): clarify theme scene content placement

Problem:
The scene editor exposed a vague content-safe-area label without explaining that it controls foreground text and card placement. The desktop CI lint gate also found four empty or unused Theme Pack leftovers.

Root cause:
The persisted safeArea field was surfaced directly as user-facing copy, while placeholder branches and helpers remained after the Theme Pack integration.

Fix:
Rename the control to Interface content area, add localized guidance and radio semantics, and remove or complete the four lint findings without changing theme behavior.

Verification:
- cd desktop && go test ./...
- cd desktop && go vet ./...
- cd desktop/frontend && pnpm typecheck
- cd desktop/frontend && pnpm check:css
- cd desktop/frontend && pnpm test:all
- cd desktop/frontend && pnpm build
- Theme Pack contract: 195 passed
- Verified the localized control in the running browser UI

* fix(desktop): unify theme confirmation dialogs

Problem:
Theme deletion, replacement imports, and export-rights checks used window.confirm, which surfaced platform-native dialogs that did not match the Reasonix settings UI.

Root cause:
The Theme Pack flows lacked a reusable in-app confirmation primitive, so both the gallery and legacy library called the browser API directly.

Fix:
Add an accessible portal-based Reasonix confirmation dialog with danger styling, safe focus behavior, Escape and backdrop cancellation, and focus restoration. Migrate all six theme confirmation call sites and localize their titles and actions.

Verification:
- pnpm typecheck
- pnpm check:css
- pnpm test:all
- pnpm build
- Browser: delete confirmation stayed in-app, Cancel received focus, Escape restored focus, and the temporary theme was deleted without a native dialog

* fix(desktop): restore theme preview and custom fonts

Problem:
- Custom UI and monospace fonts could no longer be configured from the redesigned appearance page.
- Selecting a user theme updated details without starting the advertised global preview.

Root cause:
- AppearanceOverview discarded both custom font name callbacks and omitted their editors.
- ThemeGallery limited non-immersive previews to the catalog tab.

Fix:
- Restore localized custom font editors and their persistence callbacks.
- Preview every non-immersive theme selection, including user packs.
- Add focused source-contract coverage for both behaviors.

Verification:
- pnpm test:all
- pnpm build
- pnpm check:css
- Browser verification of custom font variables, user-pack preview, and Back restoration
2026-07-18 01:11:36 +08:00
SivanCola c966d02796 Remove the Memory v5 execution compiler / 彻底移除 Memory v5 执行编译器 (#6615)
* Remove the Memory v5 execution compiler

Delete the Memory v5 execution compiler end to end: the
internal/memorycompiler package, the agent turn-start compilation and
injection gate, the LLM/heuristic task classifier apparatus, the
/memory-v5 and `reasonix config memory-v5` commands and completions,
the control/boot/config/doctor/event/eventwire wiring, the desktop
settings toggle and Wails binding, compiler metrics buckets, and the
compiler-derived memory suggestions bridge. Update GUIDE (en/zh-CN),
SPEC, the example config, the desktop README, and the site docs.

The feature delivered no provider-visible value on the default observe
path while costing per-turn state I/O and a recurring bug family
(#5316, #5329, #5342, #5468, #5486, #5596): the compact mode replaced
the user turn with an execution contract that over-planned simple
requests into runaway tool loops.

Backward compatibility:
- [agent].memory_compiler is retired via a one-time config migration
  (same pattern as redact_tool_output) with a boot notice; old configs
  load cleanly either way because unknown TOML keys are ignored.
- Legacy transcripts from releases up to v1.17.x still display
  normally: <memory-compiler-execution> blocks keep unwrapping to the
  original prompt for previews, titles, history replay, and the
  desktop transcript (internal/agent/preview.go,
  memoryCompilerDisplay.ts, memoryCitationVisibility.ts), and
  persisted compiler memory citations still render.
- The delivery evidence gate keeps its task-vs-chat heuristic, moved
  to internal/agent/task_heuristic.go with its regression table.
- On-disk compiler state directories are simply orphaned; nothing
  reads or writes them anymore.

Cache-impact: none. The compiler never touched the system prompt,
tool schemas, or provider request serialization; observe mode added no
provider-visible bytes, and compact injection was opt-in. Removing it
cannot shift the stable prefix. Verified by the release cache-hit
guard (all 8 scenarios >= 90% tail average).
Cache-guard: scripts/cache-guard.sh pass (tail_avg 92-95 across
plain-dialogue, long-dialogue, mixed-message-sizes, and tool-loop
scenarios).
System-prompt-review: no system prompt or tool schema text changed;
prompt-construction call sites in internal/agent and internal/boot
only lost the user-turn replacement branch.

* Make retired-key config migrations TOML-multiline-safe; drop unused metric buckets

Address review findings on #6615:

1. The retired-key config migrations (memory_compiler, redact_tool_output,
   agent step limits, MCP tier) scanned line-by-line without understanding
   TOML multiline strings, so a `memory_compiler = ...` example line quoted
   inside a multiline `system_prompt = """..."""` was deleted from the
   prompt and the config rewritten. All four strippers now share
   stripTOMLKeyLines, which tracks multiline basic/literal string state
   (honouring single-line strings and comments so their quote delimiters
   cannot open a multiline state) and never treats lines inside a multiline
   string as section headers or key assignments. The scanner is
   deliberately conservative: on any ambiguity it keeps lines rather than
   deleting them, and a dropped line must itself leave the scanner outside
   a multiline string. Regression tests cover the reported reproduction
   (multiline system_prompt survives byte-for-byte), literal strings,
   section headers inside strings, single-line triple-quoted values,
   comments containing triple quotes, and the real-key-next-to-string case,
   plus a full MigrateLegacyMemoryCompilerForRoot test.

2. Remove tokenSizeBucket and memorySizeBucket from desktop/metrics_app.go;
   they lost their only caller with observeMemoryCompilerStats and failed
   the desktop golangci-lint (unused) check.

Cache-impact: none; config-file migration and desktop metrics helpers are
not provider-visible surfaces.
Cache-guard: n/a beyond the base PR's passing run; no prompt construction,
tool schema, or serialization path is touched by this commit.
System-prompt-review: the migration now provably preserves user
system_prompt content (multiline regression tests); no prompt text changed.
2026-07-17 20:37:38 +08:00
SivanCola dae65e250b Fix long-run freeze on stalled error bodies and session-switch lag/content loss / 修复重试卡死、切换会话卡顿与内容丢失 (#6613)
* fix(provider): bound non-OK response body reads in the retry loop

A 502/524 gateway under load can send headers and then stall the body on a
half-open connection. http.Client deliberately has no Timeout (streams), and
ResponseHeaderTimeout no longer applies once headers arrive, so the retry
loop's error-body io.ReadAll/io.Copy could block indefinitely with no retry
notification and no user-visible progress — the turn looked frozen until the
process was killed (#6607, #6346, #5760).

readErrorBody arms a hard deadline that closes the body to unblock the read;
context cancellation already aborts it via the transport. The regression test
drives a stalled 502 body through SendWithRetry and fails via watchdog if the
loop wedges (mutation-verified).

* fix(agent): salvage event-log tail bytes before repair truncates them

repairSessionEventLogTail's os.Truncate was the only place that permanently
discards persisted transcript bytes. A torn tail is usually one partial
record, but replay also stops at a buried undecodable or out-of-order record
(the dual-writer interleave shape) — then every intact turn past it was
silently lost, surfacing as 'reopened session lost messages' (#6607, #5315).

The discarded range is now appended to a .events.jsonl.damaged salvage
sidecar (one-line JSON incident header + raw bytes) before truncation. The
sidecar name must not end in .jsonl: older binaries scanning a shared session
directory would classify it as a primary transcript. It is registered in
SessionSidecarFiles so every delete surface removes it. Preservation is
best-effort — a full disk both tears tails and fails salvage — and never
blocks the repair itself.

* perf(agent): skip snapshot saves when the transcript is provably unchanged

Desktop switch/close/prune paths snapshot defensively on every navigation.
Each snapshot paid a full transcript serialize + digest + disk probe just to
discover there was nothing to write — on large sessions that is the seconds
of UI freeze reported as switch lag (#6607, #5337, #5185; single-surface
activation pays it once per hidden tab, twice per pruned runtime).

save() now short-circuits snapshot-mode saves when in-memory bookkeeping
proves a no-op: same path, same version, same rewriteVersion, ledger revision
known, no pending load-time repair or log damage. The check runs under the
save locks, not before them, so a saver that waited on a concurrent writer
still re-evaluates against the state it must persist (locked in by
TestSaveSnapshotCapturesContentUnderSaveLock).

Only save-verified baselines arm the fast path: a baseline adopted at load
pairs the disk transcript with whatever the meta sidecar said, which lags the
transcript after an interrupted save — the first save after a load must run
in full and heal a stale ledger (TestSameContentSaveHealsStaleLedgerDigest).

* fix(desktop): drop stale topic activations instead of applying them

activateTopic applied its result unconditionally after awaiting the backend:
when a newer navigation (another topic click via a different path, a tab
switch, session restore) completed first, the stale completion flipped the
visible tab away from the user's last click AND ran the single-surface prune
that deletes every other tab's cached state — blanking the transcript the
user was looking at (#6607, #5337 'switch shows an empty session').

The App-level navigation queue serializes queued topic clicks, but its
latest() guard only covers App-level effects; the setActiveTabId + state
prune inside activateTopic were unguarded. Anchor each activation to the
existing activeNavigationSeqRef epoch (the same mechanism rewind/fork already
use) and return without applying when a newer navigation superseded it.
Mutation-verified: disabling the guard fails both stale-apply assertions.

* fix(privacy): scrub and trash the damaged-salvage sidecar with its session

Review of #6613 found two lifecycle gaps for the new .events.jsonl.damaged
salvage sidecar, both privacy-relevant since it preserves raw transcript
bytes:

- doctor redact did not recognize the suffix: RedactSessions reported success
  while secrets in the salvaged tail survived. The bytes are undecodable by
  definition, so no format-aware masking can prove them clean; following the
  existing torn-tail precedent (compacted away regardless of content), the
  scrub now deletes the sidecar outright, honors dry-run, and skips leased
  sessions like every other artifact.
- the desktop trash flow uses its own explicit artifact list, so deleting a
  session left the sidecar in the live directory and purging the trash never
  removed it. The sidecar now moves with the other transcript artifacts
  through delete, restore, and purge.

* fix(agent): re-arm the snapshot fast path after a repair is persisted

Review of #6613 found that normalizedDirty and eventLogDamaged were never
cleared on the live session after a successful save persisted the repair, so
a session that was repaired once kept paying the full serialize + digest on
every defensive snapshot until restart — the switch-lag fix was incomplete
for exactly the sessions that needed it most.

Clear the flags (and the obsolete rawMessages capture) when a save-verified
baseline lands: the write included the normalization repair and healed the
on-disk log. Nothing reads the live session's copies after a save —
checkSnapshotWrite re-loads the on-disk state and consults that object's
flags, not these.

* fix(desktop): invalidate in-flight activations when a navigation is queued

Review of #6613 found the last-click-wins guard did not cover the production
navigation queue: App.enqueueNavigation serializes clicks, so a click made
while another request runs only becomes a pending queue entry — it does not
run activateTopic and therefore never advances the controller's navigation
epoch. The running stale activation then passed the guard, flipped the
visible tab, and pruned the newer surface's cached state.

useController now exposes noteNavigationIntent (the same epoch bump the
guard checks) and App.enqueueNavigation calls it at enqueue time, so any
queued click immediately invalidates the running activation's destructive
apply. The regression test drives the REAL enqueueNavigationRequest queue,
holds the first activation mid-flight, queues a second, and asserts the
stale completion neither flips the tab nor deletes cached state; a source
assertion locks the App.tsx wiring itself. Mutation-verified both ways.

* fix(doctor): include the damaged-salvage sidecar in session bundles

Proactive sweep after the #6613 review round: session bundles exist to debug
persistence incidents, and the .events.jsonl.damaged sidecar is the primary
evidence for truncation incidents (torn tails, dual-writer interleaves) —
but sessionBundleArtifacts, another explicit artifact list, did not include
it. Bundles copy artifact contents verbatim already (only paths/refs are
redacted), so shipping the sidecar adds no new sensitivity class.

* fix(agent): migrate the damaged-salvage sidecar on overlong-name renames

Review follow-up on #6613: migrateSessionSidecars is yet another explicit
sidecar list, and it did not include SessionEventLogDamaged. When
reconcileOverlongSessionFilenames renames a transcript to a bounded stem,
the salvage sidecar stayed under the retired name — an orphan file holding
raw session bytes that no delete, trash, redact, or bundle flow could reach
(they all derive paths from the live transcript name).

Regression: TestReconcileOverlongMigratesDiagnosticSidecars now seeds the
sidecar and asserts it migrates with the rename and leaves nothing under
the retired stem (mutation-verified).
2026-07-17 19:56:28 +08:00
SivanCola 8f2c209ac1 Fix MCP persistent sessions and simplify trust / 修复 MCP 持久会话并简化授权 (#6612)
* fix(mcp): preserve stateful connections and simplify trust

Problem:
MCP identity drift invalidated existing trust receipts, while per-call writer processes repeatedly prompted stateful servers and added startup latency.

Root cause:
Host-local isolation and config-source details leaked into stable identity matching, and stdio writers used a separate one-shot process path that discarded server state.

Fix:
Migrate compatible legacy receipts, keep identity stable across host-policy changes, apply source-aware launch authorization, and reuse one persistent stdio process for reads and writes without an extra writer-isolation setting.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && env -u DEEPSEEK_API_KEY go test ./...
- cd desktop/frontend && pnpm test:typecheck
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- ./scripts/cache-guard.sh
- git diff --check

* test(mcp): use real sandbox paths in migration coverage

Problem:
The Ubuntu CI runner rejected the MCP migration test before startup because bubblewrap could not bind a hard-coded /workspace path that did not exist.

Root cause:
The test used illustrative absolute paths for enforced sandbox roots instead of creating filesystem fixtures.

Fix:
Create real temporary workspace and forbidden-read roots, then use them consistently for the trust manager and sandbox policies.

Verification:
- go test ./internal/plugin -run TestLegacyPolicyCoupledIdentityAndWorkspaceSourceMigrate -count=1
- go test ./internal/plugin -count=1
- git diff --check

* fix(mcp): complete add-and-use trust flow

Problem:
- Windows trust-state writes could fail during lock-file delete/create races.
- /mcp add did not receive user-authorized policy until the next restart.
- Project MCPs kept showing launch authorization after connecting and offered two scopes for a one-time launch gate.

Root cause:
- Windows can report access or sharing violations instead of file-exists during exclusive-create lock handoff.
- The Controller add path connected an entry before marking its saved user-config provenance.
- Desktop exposed source policy as current blocked state and reused the generic trust-scope modal.

Fix:
- Retry transient Windows lock handoff errors within the existing deadline.
- Mark explicit Controller adds as user-configured before live connection.
- Show project launch authorization only while blocked and collapse it to one durable workspace action.
- Document the persistent writer-sandbox tradeoff accurately.

Verification:
- env -u DEEPSEEK_API_KEY go test ./...
- cd desktop && env -u DEEPSEEK_API_KEY go test ./...
- cd desktop/frontend && pnpm test:typecheck
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- go test ./internal/mcptrust -count=20
- GOOS=windows GOARCH=amd64 go test ./internal/mcptrust -c
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh
- git diff --check

* fix(agent): resolve on-demand MCP approval through the shared spec policy

The delivery-mode on-demand proxy re-derived the approval mode from raw
Spec fields and missed ImplicitApproval, so a user-configured server that
runs with direct approval on the normal MCP path prompted again under the
global Ask posture. Export plugin.Spec.ToolApprovalMode as the single
resolution and route every MCPApprovalPolicy implementer through it.

* fix(desktop): keep a revoke entry for authorized project MCP servers

Hiding the whole trust panel once a launch-gated server connects also hid
the only Revoke trust button, leaving persistent project authorization
irrevocable from the desktop UI. Split the static launch-gate governance
flag from the blocked-now flag and show a calm revoke row for servers
whose workspace/session grant is currently established. Revoke stays
hidden for user-config and official sources where auto-trust would
silently re-trust on the next evaluation.
2026-07-17 19:46:03 +08:00
devCatandSivanCola f590a66ee9 fix(tui): 从 quickPicker 选择模型后正确返回 pendingModelSwitch 作为 tea.Cmd(解决 /model 切换模型问题) (#6585)
* fix(tui): 从 quickPicker 选择模型后正确返回 pendingModelSwitch 作为 tea.Cmd

handleQuickPickerKey(value receiver)调用了 runModelSubcommand 设置了
m.pendingModelSwitch,但返回了 nil 作为 tea.Cmd — bubbletea 事件循环
因此永远不会执行异步 controller 构建闭包。

slash 命令路径(runSlashCommand → "/model" 分支)已正确返回
m.pendingModelSwitch;quickPicker 路径缺少同样的检查。

后果:从 /model 的 quickPicker 选择模型后显示"正在切换到…",但构建
从未启动,"已切换到…" 从不出现,且 modelSwitchPending 保持 true,
永久阻塞后续 Enter 键。

修复:在两个 quickPicker 分支(quickPickerModel/quickPickerProviderModel
和 quickPickerProvider)的 runModelSubcommand / switchToProvider 调用后,
检查 m.pendingModelSwitch 并将其作为 tea.Cmd 返回。

测试:TestQuickPickerModelSwitch 验证 cmd != nil、modelSwitchPending
正确设置,且 cmd() 产生包含正确 ref/ctrl/oldCtrl 的 modelSwitchMsg。

Fixes #6558

* test(cli): cover provider picker model switching

Problem:
The quick-picker regression test covered direct model choices, but not provider choices that resolve immediately to a single model.

Root cause:
The provider picker uses a separate switchToProvider path, so its pending command return contract could regress independently.

Fix:
Add an isolated single-model provider test that verifies the picker returns the controller-build command and emits the expected modelSwitchMsg.

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

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

---------

Co-authored-by: SivanCola <[email protected]>
2026-07-17 16:48:56 +08:00
SivanColaandbillycat 1bd5f04d2a Polish CLI composer and responsive status UI / 优化 CLI 输入框与响应式状态栏 (#6591)
* feat(cli): polish composer and responsive status UI

* fix(cli): remove obsolete status helpers

* chore: credit status footer co-contributor

Attribution:
Credit @billycat for the full-data TUI status footer restoration and height-accounting coverage contributed in #6587.

Relationship:
This PR carries that behavior forward in the redesigned responsive footer. This attribution-only commit does not change the runtime tree.

Verification:
Confirmed this commit has the same tree as its parent.

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

* polish(cli): refine receipt and themed footer

Problem:
The wide footer overemphasized per-turn metadata, separated effort from its model context, and used faint or hard-coded colours that did not preserve the same hierarchy across dark and light terminals.

Fix:
Use a single receipt boundary with softer separators, group model/effort/work together, add a theme-specific subtle contrast token, and render explicit effort through each theme's semantic info colour. Keep dark, light, and no-colour geometry identical.

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

* polish(cli): tint composer borders with theme accent

Problem: The composer top and bottom borders stayed neutral, so the input area did not reflect the active CLI theme.

Root cause: refreshCLIStyles bound the composer border foreground to the neutral border token.

Fix: Bind both composer borders to the active accent and cover every dark and light theme style plus NO_COLOR.

Verification: Focused composer theme test and the full internal/cli suite pass with an isolated user home.

* fix(cli): localize persistent footer labels

Problem: Chinese and Traditional Chinese sessions still showed English metric labels in the persistent footer.

Root cause: The new footer embedded MODEL, EFFORT, WORK, CACHE, CTX, COMPACT, JOBS, and BAL as literals outside the i18n catalog.

Fix: Add localized footer labels, use them at every metric call site, and calculate model width from the active label.

Verification: i18n catalog tests and the full internal/cli suite pass with an isolated user home.

* fix(cli): spell out footer shortcuts

Problem: The persistent footer used terminal shorthand for Shift+Tab and Ctrl+Y, which was difficult to recognize at a glance.

Root cause: The compact locale strings preferred symbolic key notation without a responsive fallback for the extra width of readable names.

Fix: Spell out both shortcuts in every locale, wrap semantic groups on medium terminals, and hide optional shortcut help only when a complete key-name group cannot fit.

Verification: The i18n catalog and full internal/cli test suites pass with an isolated user home.

* fix(cli): localize work mode values

Problem:
Chinese CLI sessions showed an English work-mode value such as '模式 balanced', unlike the localized desktop UI.

Root cause:
The CLI used one helper both for stable command identifiers and user-facing profile labels, so English command names leaked into localized status and notices.

Fix:
Separate stable work-mode names from localized display labels, add English, Simplified Chinese, and Traditional Chinese catalog entries, and keep slash-command completion values unchanged.

Verification:
- go test ./internal/i18n -count=1
- go test ./internal/cli -run 'Test(StatusFooter|WorkMode|RuntimeProfileDisplay|RenderWorkModes)' -count=1
- git diff --check

* fix(cli): use platform image paste shortcut in tests

Problem: The image-paste selection regression test always sent Ctrl+V, so the Windows CI job could not enter its Alt+V image-paste path.

Root cause: The test ignored the platform mapping already enforced by imagePasteShortcut.

Fix: Select Ctrl+V on macOS/Linux and Alt+V on Windows, and make the test name and diagnostics platform-neutral.

Verification: The isolated internal/config, internal/i18n, and internal/cli suites passed; the Windows/amd64 internal/cli test binary also cross-compiled successfully.

* docs(cli): document responsive composer controls

Documentation: Explain footer states and telemetry, theme-aware composer behavior, platform-specific text and image paste, and the stable mode shortcuts in the English and Chinese CLI guides.

Website: Mirror the operation and shortcut guidance on the public docs page and refresh the CLI homepage summary.

Changelog: Record the responsive composer, localized footer, clipboard split, and theme behavior under Unreleased.

Verification: The site test suite and Astro static build passed, and git diff --check reported no whitespace errors.

---------

Co-authored-by: billycat <[email protected]>
2026-07-17 16:27:09 +08:00
SivanCola 0998795927 fix(desktop): surface MCP re-verification action (#6605)
Problem:
MCP identity drift was blocked before startup, but the server list still offered Retry, which could only repeat the same preflight failure.

Root cause:
Recorded MCP failures kept only a display string, so the desktop could not distinguish re-verification requirements from ordinary connection failures.

Fix:
Preserve identity-drift failures as structured state, surface Reverify directly in the server row, keep OAuth and ordinary retry precedence, and reconnect failed servers after explicit trust confirmation.

Verification:
- go test ./internal/plugin
- go test ./... (with the live DeepSeek probe disabled)
- cd desktop && go test ./...
- pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- pnpm typecheck
- pnpm test:typecheck
- pnpm check:css
2026-07-17 12:16:01 +08:00
SivanCola 9b54b9f893 fix(desktop): Persist workspace panel and harden Windows openers / 持久化工作区折叠并加固 Windows 外部打开 (#6588)
* fix(desktop): persist workspace panel and harden Windows openers

Remember the workspace dock open/collapsed preference across restarts
(regression from always-default-open), discover custom VS Code installs via
App Paths, improve Windows Terminal icons, and launch console shells through
detached start so PowerShell/CMD no longer flash-exit.

* fix(desktop): shell-free Windows console open and real WT icons

Address PR #6588 review:

- Launch PowerShell/CMD via ShellExecuteW with lpDirectory instead of
  cmd /c start, so workspace paths with shell metacharacters cannot be
  re-parsed and start title quoting cannot mis-bind.
- Look up Windows Terminal icons under Program Files\WindowsApps package
  layout first; App Execution Alias dirs only expose wt.exe stubs.
- Add pure, injectable plan/candidate tests that cover special-character
  workdirs without starting a real console process.

* fix(desktop): prefer renderable WT icon over zero-byte alias

When Program Files\WindowsApps cannot be enumerated, skip zero-byte wt.exe
App Execution Aliases and use a PowerShell (or other non-zero) icon fallback
so the menu does not show a blank glyph. Add deterministic pick() assertions.
2026-07-17 00:23:33 +08:00
SivanCola 0afcea408d Restore historical message timestamps and simplify history navigation / 恢复历史消息时间戳并简化历史入口 (#6586)
* fix(desktop): remove redundant history navigation

* fix(desktop): restore historical message timestamps
2026-07-16 23:48:14 +08:00
SivanColaandopen 6826411dd1 Restore v0.53 secret handling semantics (#6583)
* Restore v0.53 secret handling

* Harden verbatim artifact storage

---------

Co-authored-by: open <[email protected]>
2026-07-16 22:42:34 +08:00
SivanColaandgithub-actions[bot] db4be5e6ba Prepare v1.17.14 release notes / 准备 v1.17.14 中英更新日志 (#6580)
* docs(release): prepare v1.17.14 notes

Generate a bilingual, product-focused draft from merged pull request metadata. The reviewed catalog will drive the website, desktop app, and GitHub releases.

* fix(release): persist guide link revisions

Problem: Generated release-note guide URLs used the transient HEAD ref, which could stop resolving after the preparation branch was deleted.

Root cause: The generator interpolated the git revision expression directly into public GitHub URLs.

Fix: Resolve the source revision to an immutable commit SHA and update the v1.17.14 guide links to the reviewed candidate commit.

Verification: node --check scripts/generate-release-notes.mjs; node scripts/release-notes.mjs validate; node --test scripts/release-notes.test.mjs; rendered-link and privacy scan; git diff --check

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-16 11:18:33 +00:00
SivanCola cc9b1f52e6 fix(release): allow newer catalog entries (#6579)
Problem: The release-notes preparation workflow failed after generating a new version because its test required 1.17.13 to remain the first catalog entry.

Root cause: The test duplicated mutable release data as a hard-coded constant even though loadCatalog already validates descending version order.

Fix: Remove the stale version assertion while retaining schema, ordering, and uniqueness validation through the catalog loader.

Verification: node scripts/release-notes.mjs validate; node --test scripts/release-notes.test.mjs; git diff --check
2026-07-16 11:03:16 +00:00
SivanCola 751acd4e6f feat(release): add bilingual product changelog (#6576)
Problem:
Release notes were assembled independently from commit-oriented GitHub output, leaving the website, desktop updater, and CLI/Desktop releases without one reviewed product narrative.

Root cause:
There was no structured bilingual release catalog or review workflow shared by the site, desktop UI, and tag publishers.

Fix:
Add a validated release catalog and DeepSeek-assisted draft workflow, publish responsive website changelog pages with shared navigation, link Desktop update surfaces to the web history, and render the reviewed notes into CLI and Desktop GitHub releases.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- npm test and Astro build in site/
- pnpm run build in desktop/frontend/
- actionlint for all affected workflows
- git diff --check

Cache-impact: none - release metadata, website UI, desktop links, and publication workflows do not change provider-visible prompts, tool schemas, request serialization, or ordering.
Cache-guard: existing scripts/cache-guard.sh remains applicable; no cache-sensitive path changed.
2026-07-16 18:49:57 +08:00
SivanCola 5c2c3d5d64 fix(desktop): Show MCP npm connection endpoints / 显示 MCP npm 连接端点 (#6559)
* fix(desktop): show MCP npm connection endpoints

Problem:
MCP npm launcher failures such as ECONNREFUSED were collapsed to the error code, hiding the registry and refused proxy endpoint users need to repair their configuration.

Root cause:
The desktop summary extracted only the npm code and discarded network context already present in stderr. The same errors were also grouped as generic failures.

Fix:
Preserve the credential-free registry host and connection endpoint in summaries, classify common npm network codes correctly, and cover private-registry redaction.

Verification:
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- real npx filesystem MCP failure through an unreachable proxy
- real npx filesystem MCP startup through the official registry

* fix(desktop): parse legacy npm network errors

Problem:
Reasonix supports Node 18 environments whose npm releases emit network failures with the legacy npm ERR! prefix, leaving the registry and refused endpoint hidden.

Root cause:
The desktop MCP error summary recognized only the newer npm error code prefix.

Fix:
Accept both npm error and npm ERR! prefixes and add a regression based on the real npm 9 failure format.

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/capabilities-panel-actions.test.ts
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend test
- pnpm --dir desktop/frontend build
- real npm 9.9.4 npx failure through an unreachable proxy
2026-07-16 17:56:18 +08:00
SivanCola 7e32590a68 fix(hooks): Windows plugin hook compatibility (#6550)
Problem:
On Windows, cmd.exe does not expand POSIX-style plugin-root variables or provide
`sh`, so plugin hooks received a literal ${REASONIX_PLUGIN_ROOT} path and explicit
`sh -c` / `bash -c` hooks failed before their scripts could run. Localized cmd.exe
errors were also surfaced to the UI as mojibake.

Root cause:
- expandPluginRoot only recognized ${CLAUDE_PLUGIN_ROOT}/$CLAUDE_PLUGIN_ROOT, and
  env expansion ran after host vars were set, so host-provided paths could be
  accidentally expanded.
- spawnCommand always went through cmd.exe on Windows, which cannot find a Git Bash
  installed outside its PATH.
- Hook stdout/stderr were surfaced as raw bytes, so legacy code-page (GB18030)
  output reached the renderer as mojibake.

Fix:
- Expand ${CLAUDE_PLUGIN_ROOT}/${REASONIX_PLUGIN_ROOT} plus their unbraced $NAME and
  Windows %NAME% spellings via a single-pass scanner that never re-expands the
  resolved root; expand plugin env before writing host vars.
- Route explicit bare `sh -c` / `bash -c` hooks on Windows through the shared
  hardened Git Bash discovery, preserve explicit interpreter paths, and return an
  actionable error when no usable Bash is found.
- Normalize legacy code-page hook output to valid UTF-8, preserving truncated
  multibyte prefixes and leaving UTF-8-native output byte-for-byte.

Verification:
- go test ./internal/hook ./internal/pluginpkg ./internal/config
- go test -race ./internal/hook
- go vet ./internal/hook ./internal/pluginpkg
- GOOS=windows GOARCH=amd64 go test -c ./internal/hook
- CI: all checks green (cache-impact, desktop-windows, test (windows-latest), race,
  CodeQL, govulncheck)

Cache-impact: none - only local plugin hook spawning and output decoding change;
provider-visible prompts, tool schemas, and ordering are unchanged.
2026-07-16 14:14:23 +08:00
SivanCola a142e6e731 Merge pull request #6549 from SivanCola/fix/windows-auxiliary-app-icons
Fix Windows launcher and support executable icons / 修复 Windows 启动器及辅助程序图标
2026-07-16 14:13:53 +08:00
SivanCola 06314c99a2 Merge pull request #6553 from SivanCola/fix/desktop-download-deep-link
fix: Open desktop downloads for manual updates / 修复手动更新下载深链
2026-07-16 11:47:24 +08:00
SivanCola 94a26c36b7 fix: route manual updates to desktop downloads
Problem: Manual-only desktop updates opened the generic install anchor with the npm pane selected, forcing users to find the desktop downloads themselves.

Root cause: The updater manifest only linked to #start, while the site activated the desktop pane and highlighted the detected OS only for an in-page click.

Fix: Add a desktop-specific install deep link, activate and scroll to its pane on page load, preserve plain #start behavior, and keep updater manifests and release checks aligned.

Verification: npm --prefix site test; npm --prefix site run build; cd desktop && go test ./cmd/sign .; pnpm --dir desktop/frontend typecheck; pnpm --dir desktop/frontend check:css; local browser deep-link verification.
2026-07-16 11:37:26 +08:00
SivanCola a56bf8a358 Fix Windows resource test lint 2026-07-16 11:18:58 +08:00
SivanCola e18d8c4451 fix(desktop): restore Windows support executable icons 2026-07-16 10:57:25 +08:00
SivanCola 5342a8aedc Merge pull request #6544 from SivanCola/fix/credential-store-env-isolation
Isolate all stored credential env keys / 隔离所有已保存凭据环境变量
2026-07-16 09:42:47 +08:00
SivanCola fad0933b48 fix: isolate all stored credential env keys 2026-07-16 09:30:59 +08:00
SivanCola 0869add2c5 Merge pull request #6542 from SivanCola/fix/delivery-readiness-recovery
Fix Delivery recovery and isolate concurrent writers / 修复交付恢复与并发写入隔离
2026-07-16 09:06:07 +08:00
SivanCola 9a5011ceb7 test(worktree): cover Windows long checkout paths 2026-07-16 08:37:07 +08:00
SivanCola bfb0bf4d2e fix(worktree): harden Windows isolation 2026-07-16 08:30:14 +08:00
SivanCola fbdedc813f test: make worktree checks portable on Windows 2026-07-16 04:01:13 +08:00
SivanColaandttmouse 03b39a6554 fix(delivery): recover readiness and isolate concurrent writers
Co-authored-by: ttmouse <[email protected]>
2026-07-16 03:51:42 +08:00
SivanCola f88b636414 Merge pull request #6540 from esengine/codex/stabilize-bot-lock-test
Bound bot lock stress test on Windows / 限制 Windows Bot 锁压力测试
2026-07-16 02:29:15 +08:00
SivanCola de3927e2d1 test: bound bot config writer stress 2026-07-16 02:16:37 +08:00
SivanCola 1a0f60f332 Merge pull request #6537 from esengine/codex/harden-nsis-install
Harden NSIS setup for release workflows / 加固 NSIS 发版安装
2026-07-16 01:56:20 +08:00
SivanCola 65ddec5c43 ci: use curl for pinned NSIS archive 2026-07-16 01:43:52 +08:00
SivanCola d03c89a996 ci: make NSIS setup independent of Chocolatey 2026-07-16 01:33:13 +08:00
SivanCola b589b9a524 Merge pull request #6536 from esengine/fix/release-gate-hardening
Harden release-critical provider and credential paths / 加固发版关键 Provider 与凭据路径
2026-07-16 01:07:40 +08:00
SivanCola f5179b4de4 fix: harden release-critical provider and credential paths
Merge cumulative Anthropic-compatible usage across stream events, keep saved credentials outside model-controlled tool environments and read paths, and make desktop session teardown waits concurrent with semantic timing coverage.
2026-07-16 00:50:02 +08:00
SivanCola 392b4a34b9 Merge pull request #6498 from SivanCola/fix/plan-delivery-yolo-rebuild
fix(agent): Unify strict read-only subagent boundaries / 统一严格只读子代理边界
2026-07-15 23:10:27 +08:00
SivanCola e864fc48be docs: describe the implemented strict read-only boundary precisely
The construction boundary is the shared pairing NewReadOnlyAgent /
RunReadOnlySubAgentWithSession; the registry filter removes host-mutating
tools and authority-less MCP readers while receipt-matched trusted readers
may still start on demand; and the Plan-mode MCP block is a hard phase
block that approval cannot release — it lifts only when Plan exits. GUIDE
in both languages, SPEC, the website, and MIGRATING now match the enforced
semantics.
2026-07-15 22:58:11 +08:00
SivanCola 9bbd138695 fix(agent): make reader authorization tamper-proof at MCP dispatch
Reader-authorized calls now carry an immutable execution intent into
dispatch: the agent pins the reader basis plus the capability fingerprint
in the call context, and remoteTool.ExecuteWithImages re-verifies both
linearizably under the owning client's toolsMu — the same lock every trust
mutation (catalog revocation, live security reconciliation) takes. A call
approved as a non-destructive reader can therefore never promote itself
into the one-shot writer lane or reach tools/call after a concurrent
revocation or reclassification; it returns an actionable re-verify error
with zero extra process starts and zero tool calls, while writer-approved
calls keep their lane unchanged. Covered by a helper-counter regression
that flips trust between authorization and dispatch and asserts both
counters stay flat, plus a stale-fingerprint refusal.

Strict read-only execution now also requires a positive trust authority
for MCP-backed tools: the no-TrustManager compatibility paths (server
hints, legacy read-only config) keep their historical classification for
ordinary sessions but can no longer admit tools into a strict child's
registry or its on-demand host-startup gate. A missing trust store fails
closed with zero starts and zero executions.

NewReadOnlyAgent joins RunReadOnlySubAgentWithSession as the shared
construction pairing (permanent ReadOnlyExecution flag plus the final
registry filter), and the two-model planner now builds through it instead
of assembling the pieces by hand.
2026-07-15 22:58:10 +08:00
SivanCola fdaf89c250 docs: list the strict read-only entrances and their boundary
Every strict read-only child shares one construction boundary
(RunReadOnlySubAgentWithSession); the guides now list the entrances —
read_only_task, read-only parallel_tasks, read_only_skill, CLI review,
desktop preview/review subagents, and the two-model planner — and state
the boundary properties: final registry filtering of writers/destructive/
untrusted-reader/host-mutating tools, post-resolution use_capability
re-checks, receipt+identity+cached-fingerprint-gated on-demand reader
startup with no trust creation or upgrades, zero executions on live
drift, no auto_review privilege elevation, and fail-closed local prompts.
Both languages also state how this layer is stricter than the main Plan
workflow.
2026-07-15 22:08:16 +08:00
SivanCola 5f5c869a94 Merge remote-tracking branch 'origin/main-v2' into codex/pr6498-readonly-boundary 2026-07-15 22:04:56 +08:00
SivanCola 912dee6902 Merge pull request #6482 from SivanCola/feature/mcp-settings-simplification
feat(mcp): Trusted execution, signed catalog, and isolation / MCP 可信执行、签名目录与隔离
2026-07-15 22:02:12 +08:00
SivanCola 0c61f6b544 docs: fix Plan and destructive-reviewer contract drift on public surfaces
The website still claimed MCP writers use the normal Ask/Auto/YOLO path while
planning; it now describes the three execution layers — ordinary sessions,
the main Plan workflow with MCP writer/destructive targets and untrusted
readers blocked until approval, and strict read-only children that never
expose writers. MIGRATING.md no longer says destructive calls fail closed on
an unavailable reviewer (they never consult one — every call takes a fresh
human approval) and its receipt-migration promise now matches the pre-start
gate behavior. The PR description's auto_review fallback wording was updated
to the fresh-human contract alongside this commit.
2026-07-15 18:55:34 +08:00
SivanCola 60fc21f9d2 fix(mcp): migrate legacy receipts at the pre-start gate, widen credential keys
The pre-start identity gate returned before initialize/tools-list while the
legacy URL migration only ran inside the post-handshake evaluation, so eager
and cache-miss servers with a pre-rollout receipt were blocked with a manual
re-verification instead of migrating. start() now upgrades a receipt whose
identity exactly matches this spec's legacy URL fingerprint before treating
the identity as changed; no capabilities exist at that point, so the drift
comparison is skipped there (the tool snapshot is untouched and the
post-handshake evaluation still runs the full drift check). Covered by a real
StartAll regression over the Streamable HTTP harness: legacy receipt starts
and migrates, credential rotation keeps starting, and a moved resource scope
still blocks before any network handshake.

The credential query classifier now recognizes whole families by normalized
suffix (*token, *secret, *password/*passwd, *apikey, *signature) plus an
explicit key/auth family (key, access_key, secret_key, private_key, auth_key,
app_key, client_key, subscription_key, shared_key, bearer, credentials), so
auth_token, refresh_token, id_token, and x-api-key values stay out of
identity/cache hashes while workspace, tenant, region, and similar resource
parameters remain identity-bearing.
2026-07-15 18:55:34 +08:00
SivanCola 2b88997bab docs: align Plan and MCP approval semantics with enforcement
Plan is documented as a plan-first workflow, not an all-tools read-only
mode: built-in writers keep Permissions/Sandbox while installed MCP and
proxy-resolved MCP writer/destructive targets plus untrusted readers stay
blocked until plan approval, returning to their normal approval flow when
Plan exits. The "every writer follows Ask/Auto/YOLO while planning"
claims are removed from GUIDE/SPEC/MIGRATING.

Destructive MCP calls document as always requiring a fresh human
approval that no reviewer, Auto/YOLO posture, or session grant can
answer. auto_review documents its reviewer-entry conditions (prompt,
writes hits, and auto calls the global posture would Ask about), the
finality of a successful verdict, and the fresh-human degradation for
missing or failed reviewers. MIGRATING.md gains the credential-aware MCP
URL identity migration window.
2026-07-15 18:37:09 +08:00
SivanCola 3ad8dfa431 fix(approval): degrade unavailable auto_review to fresh human approval
A successful auto_review verdict — allow or deny — stays final. When the
configured reviewer is missing, times out, errors, or returns no verdict,
the call no longer falls into the ordinary approval flow: it now requires
a strict fresh human decision that Auto/YOLO, the approved-plan window,
and session grants cannot answer, and the decision never mints a session
grant of its own. Non-interactive gates keep failing closed before any
approver is consulted, and approval_mode=auto still inherits the global
posture without consulting the reviewer when that posture already allows
the call.

guardian.ReviewVerdict exposes the authentic-verdict/failure distinction
(transport errors, timeouts, unparseable assessments) with unchanged
circuit-breaker bookkeeping, while Review keeps the legacy fold-into-deny
contract for the empty-reviewer routing.
2026-07-15 18:37:08 +08:00
SivanCola a7536482c3 fix(mcp): credential-aware identity fingerprints and exact launcher pins
- normalizeIdentityURL redacts URL userinfo and credential query values
  (token, access_token, api_key, authorization, auth, password, passwd,
  secret, client_secret, credential, signature, sig — matched
  case-insensitively after removing separators) while keeping resource
  scope parameters such as workspace and tenant; query keys sort stably
  and fragments drop. Only the placeholder structure survives, so
  credential rotation keeps trust while a scope change still re-verifies.
  Network requests keep using the raw configured URL.
- The previous normalization survives as a read-only legacy calculator:
  managerEvaluate migrates receipts and LoadCachedSchemaForSpec rewrites
  schema-cache entries in place when nothing else drifted, so the rollout
  and credential rotation never force a re-trust or a re-handshake.
  Removal window: two minor releases (docs/MIGRATING.md).
- managerEvaluate is the single evaluation entry for every executing
  path — live handshake, lazy cache preflight, cached-tool trust, and the
  trust refresh — and routes official servers through EvaluateOfficial
  with the current signed reader allowlist.
- Git launcher pins accept exactly complete 40- or 64-hex object names;
  intermediate lengths resolve through git ls-remote like any ref. uvx
  pins reject PEP 440 wildcard versions while keeping exact prerelease,
  post, dev, epoch, and local pins, and the official catalog validation
  shares both predicates with mutable launcher locks.
- slug() suffixes Windows-reserved device stems (CON, PRN, AUX, NUL,
  COM1-9, LPT1-9) so cache JSON, stats files, and MCP state directories
  never name a DOS device; ordinary safe names stay byte-identical and
  old reserved-name paths are not migrated (they never worked on
  Windows).
2026-07-15 18:36:52 +08:00
SivanCola 5092b23ddb fix(mcp): enforce live official allowlists and receipt integrity
- EvaluateOfficial: official catalog servers evaluate reader authority
  against the current signed allowlist instead of the receipt's historical
  trusted_reader flags. A reader removed from the catalog loses authority
  immediately; a newly listed reader is granted only while its
  already-verified capability snapshot still matches and it remains a
  non-destructive reader; a receipt issued by a different catalog entry
  grants nothing. The receipt keeps identity and drift snapshots only, and
  its persisted format is unchanged.
- MigrateIdentityFingerprint: a receipt whose identity is exactly the
  legacy URL fingerprint upgrades atomically to the current one when no
  live capability drifted. Only digests are compared; URLs and credential
  values are never read or persisted here.
- stripDisplayFields: dependentRequired and legacy dependencies are
  containers keyed by user property names — constraint keys named like
  annotations (title, description, examples) survive, array and schema
  value forms are both handled, and schema-form values still shed their
  display-only annotations.
- Receipt identity: Source leaves the receipt key, an explicit user
  receipt replaces (and can never be replaced by) a legacy import, and
  load-time normalization dedupes older duplicated states — explicit user
  over legacy import, then latest verification — without a store version
  bump. Session and official global scopes keep their own slots.
2026-07-15 18:36:34 +08:00
SivanCola ef5bfd07a3 Merge remote-tracking branch 'origin/main-v2' into feature/mcp-settings-simplification
# Conflicts:
#	internal/sandbox/seatbelt_windows.go
#	internal/sandbox/seatbelt_windows_test.go
#	internal/winsandbox/coordination_windows.go
#	internal/winsandbox/coordination_windows_test.go
#	internal/winsandbox/sandbox.go
#	internal/winsandbox/sandbox_windows.go
#	internal/winsandbox/sandbox_windows_test.go
2026-07-15 17:26:29 +08:00
SivanCola 53f992cb34 fix(windows): stop loader-grant heap corruption and icacls cost
TestWindowsSandboxAccessSIDScopes released the current-user SID with
FreeSid, but currentProcessUserSID returns a Go-allocated copy
(SID.Copy), so FreeSid corrupted the Windows process heap and the
winsandbox test binary exited with STATUS_HEAP_CORRUPTION (0xc0000374).
Go-allocated SIDs are garbage collected; drop the FreeSid.

Temporary image-loader grants were removed with one icacls child
process per SID while holding the global ACL-mutation mutex, adding
multi-second serialized teardown to every sandboxed start and pushing
concurrent MCP reader startups past their deadlines on CI. Revoke the
recorded loader SIDs through one SetEntriesInAcl(REVOKE_ACCESS) update
instead, both after CreateProcess and in the crash-residue sweep.
2026-07-15 17:18:48 +08:00
SivanCola 3212786e45 fix(mcp): harden identity, cache, and transport trust boundaries
Sweep of the remaining trust-boundary gaps around the reviewed MCP
security fixes, each with a focused regression test:

- Official catalog servers: reverify the installed package digest
  before any process or network startup, bind the catalog transport to
  the configured transport, and require immutable launcher locators
  (exact npm/PyPI versions or full Git commits). Desktop and CLI
  inspect/revoke flows resolve the same official trust metadata as
  boot, so they can no longer downgrade an official server to a custom
  spec, and a manual catalog refresh applies fresh revocations to every
  live shared host immediately.
- Identity fingerprints: environment key case folds only on Windows
  (env names are case-sensitive on Unix), forbid_read roots join the
  identity, display-field stripping no longer erases user-defined
  schema properties named like annotations, and receipts never match
  across different config sources.
- Schema cache: SpecFingerprint binds the server name and normalized
  URL, sanitized slugs get a content hash so confusable names cannot
  share cache files or MCP state directories, and cache writes go
  through the shared atomic-write helper so Windows replaces work under
  concurrent readers.
- Tool listing: reject empty or duplicate raw tool names before they
  can alias each other's approvals, and give one-shot writer lanes the
  same bounded settle window as the reader lane.
- HTTP transport: configured credential headers are no longer resent
  when a server redirects to a different origin.
- Concurrency: remoteTool security fields are read through a snapshot
  taken under the owning client's toolsMu; the trust preflight builds
  its cached snapshot outside that lock (a snapshot inside the critical
  section deadlocked SetSpecTrust).

Lowercase-safe cache slugs keep their filenames; mixed-case or
punctuated server names re-fetch their schema cache once after upgrade.
Portable tree digests now exclude POSIX modes and disable autocrlf so
signed catalog packages verify identically on Windows checkouts.
2026-07-15 17:18:38 +08:00
SivanCola 95c023b626 Merge pull request #6516 from SivanCola/chore/retire-windows-sandbox
chore(sandbox): Retire the Windows native backend / 退役 Windows 原生沙箱后端
2026-07-15 17:03:41 +08:00
SivanCola c5427273d2 test(sandbox): expect Windows retired-backend remediation wording
TestUnavailableMessageIsActionable still required the config-edit
remediation on every platform, but Windows now reports the backend as
retired with the effective mode fixed to off, and pointing users at a
Settings edit the platform ignores would be misleading. Assert the
platform-appropriate actionable content instead.
2026-07-15 16:52:42 +08:00
SivanCola c0512555ca fix(desktop): match retired-backend runtime escape reason for localization
The English SandboxEscapeRuntimeReason was reworded to "The OS sandbox
could not start this command", which no longer matched the frontend
localizeApprovalReason predicates, so a Chinese desktop UI paired with an
English backend would show the raw English reason. Keep the old matchers
for older CLI backends and add the new phrase, with a zh-CN regression
assertion.
2026-07-15 16:44:15 +08:00
SivanCola 8c02916042 chore(sandbox): retire Windows native backend 2026-07-15 16:27:50 +08:00
SivanCola 938b66aeaa fix(windows): scope loader grants to process startup 2026-07-15 16:20:07 +08:00
SivanCola 313defe0cd fix(windows): serialize ACL mutation phases 2026-07-15 16:05:07 +08:00
SivanCola dd5c46f95e fix(windows): isolate concurrent MCP ACL updates 2026-07-15 15:50:03 +08:00
SivanCola 6348d13d3a fix(windows): preserve sandboxed MCP startup 2026-07-15 15:40:36 +08:00
SivanCola 9b6fe02dff fix(windows): scope MCP AppContainer ACLs 2026-07-15 15:28:16 +08:00
SivanCola 845142312b fix(sandbox): expose temporary MCP executable safely 2026-07-15 15:12:34 +08:00
SivanCola 33d23238a1 Merge remote-tracking branch 'origin/main-v2' into feature/mcp-settings-simplification
# Conflicts:
#	internal/agent/planmode_test.go
2026-07-15 15:04:05 +08:00
SivanCola 83639eb0e5 fix(mcp): close trust and lifecycle review gaps 2026-07-15 14:54:40 +08:00
SivanCola ad9c3fc138 Merge pull request #6504 from SivanCola/fix/post-edit-context-refresh
fix(agent): Ground edits without forced rereads / 无需强制重读即可同步编辑
2026-07-15 14:50:47 +08:00
SivanCola 249f9e6a74 fix(agent): bound edit receipts and refresh previews 2026-07-15 14:35:07 +08:00
SivanCola c8ee98e0d5 fix(agent): ground edits without forced rereads 2026-07-15 14:35:06 +08:00
SivanCola 988190f375 Merge pull request #6497 from SivanCola/fix/serial-todo-guardrails
fix(agent): enforce serial todo progress / 强制串行 Todo 推进
2026-07-15 14:29:52 +08:00
SivanCola 3ff177d268 fix(agent): unify strict read-only MCP execution boundary 2026-07-15 14:27:45 +08:00
SivanCola d78c8e18cf Merge remote-tracking branch 'fork/feature/mcp-settings-simplification' into codex/pr6498-readonly-boundary 2026-07-15 14:18:43 +08:00
SivanCola bd149dc8d1 fix(mcp): exclude credential values from cache identity 2026-07-15 14:18:23 +08:00
SivanCola ddcb0adcc0 Merge latest PR head before validation 2026-07-15 14:16:18 +08:00
SivanCola 32c479f972 docs(todo): describe single current item continuity 2026-07-15 14:15:43 +08:00
SivanCola a9066729f3 fix(todo): seed layered plans with executable child 2026-07-15 14:13:20 +08:00
SivanCola 9c60e593c8 Merge remote-tracking branch 'fork/feature/mcp-settings-simplification' into codex/pr6498-readonly-boundary 2026-07-15 14:12:49 +08:00
SivanCola 5fcca24382 fix(mcp): preserve trusted reader preflight snapshots 2026-07-15 14:12:34 +08:00
SivanCola c7ace7247f ci: retrigger checks after Actions scheduling recovered
The pull_request events for c8723be96 and the first retrigger commit
were dropped while the repo's ci.yml/codeql.yml scheduling was stuck
(their zombie queued runs from May return HTTP 500 on cancel). Actions
is scheduling new events again, so fire one more synchronize event.
2026-07-15 14:12:19 +08:00
SivanCola 851c153e3f Merge remote-tracking branch 'fork/feature/mcp-settings-simplification' into codex/pr6498-readonly-boundary
# Conflicts:
#	internal/agent/agent.go
2026-07-15 14:10:03 +08:00
SivanCola 868d3fa913 Merge remote-tracking branch 'origin/main-v2' into feature/mcp-settings-simplification
# Conflicts:
#	desktop/app.go
2026-07-15 14:01:29 +08:00
SivanCola 267b67b818 Merge main-v2 into fix/serial-todo-guardrails 2026-07-15 14:00:53 +08:00
SivanCola 568da85b5a Merge remote-tracking branch 'fork/feature/mcp-settings-simplification' into feature/mcp-settings-simplification 2026-07-15 13:55:57 +08:00
SivanCola c636f017a8 feat(mcp): enforce trusted execution boundaries 2026-07-15 13:55:34 +08:00
SivanCola 6463e6bbbd ci: retrigger checks
GitHub Actions dropped the CI and CodeQL run creation for c8723be96
(only the label and cache-impact workflows were scheduled, and a PR
close/reopen did not recreate them). Empty commit to re-fire the
pull_request synchronize event.
2026-07-15 13:53:47 +08:00
SivanCola c8723be962 Preserve the single in_progress contract for phase chains
The first level-aware pass required a phase and its active sub-step to
both be in_progress, contradicting the provider-visible todo_write
contract (exactly one in_progress item). The active sub-step is now the
only in_progress item: its phase stays pending while sub-steps complete
in order, then the phase becomes the single in_progress item and signs
off last, so the tool description and schema stay true byte-for-byte.

todo_write now rejects a level-1 sub-step with no phase above it, and
AdvanceSerialTodo advances legacy orphan sub-steps as standalone steps
instead of stalling the list with no current item.
2026-07-15 13:43:06 +08:00
SivanCola ddca95f016 Merge pull request #6460 from SivanCola/feature/reasonix-guard
Add offline recovery Guard and Safe Mode / 增加离线恢复守卫与安全模式
2026-07-15 13:26:34 +08:00
SivanCola 1b9ff514d1 fix(recovery): harden pre-ready exits, rollback serialization, and Safe Mode gates
- Desktop shutdown resets the crash-loop counter and blesses a
  probationary update only after the window reached domReady, so cleanly
  quitting a build that boots but never paints keeps the incomplete
  startup record and the rollback backups.
- Serialize cross-process pending-update transitions (prepare, rollback,
  commit, cancel) with a repair-state file lock: two concurrently
  launched Guards could otherwise fight over restoreReleaseUnit's fixed
  staging paths and re-install the new release over a completed rollback.
- Safe Mode now also drops host-supplied ACP MCP servers and skips the
  Memory v5 compiler, matching the documented recovery boundary.
- Pin the normal-mode tool surface alongside the existing Safe Mode pin.
2026-07-15 13:14:15 +08:00
SivanCola fb37b9b5b6 Make the serial todo contract level-aware
Two-level task lists now validate as one current chain: the active
level-0 phase stays in_progress while its level-1 sub-steps complete in
order, and the phase itself signs off last. complete_step refuses a
phase whose sub-steps are unfinished, todo_write continuity protects
every in_progress chain item, and the shared evidence.AdvanceSerialTodo
helper drives live host advance, session rebuild, and desktop history
replay so all three stay chain-valid. Flat lists keep their existing
rules and messages.
2026-07-15 13:03:53 +08:00
SivanCola 30faf50960 fix(desktop): align MCP details and compact layout 2026-07-15 12:30:16 +08:00
SivanCola 1bb724a7ee Merge origin/main-v2 into feature/mcp-settings-simplification 2026-07-15 11:59:44 +08:00
SivanCola 791ec4d03c fix: retire hidden agent step limits 2026-07-15 11:57:59 +08:00
SivanCola fabdc9ef06 fix(desktop): preserve Windows paths in notices 2026-07-15 11:51:03 +08:00
SivanCola af89911ed3 chore(plan): remove obsolete trust helper 2026-07-15 11:47:18 +08:00
SivanCola b9a1f8d5f2 Merge pull request #6500 from SivanCola/fix/reasoning-language-imperative-zh-block
fix(agent): imperative zh reasoning-language block / 强化中文思考语言注入措辞
2026-07-15 11:42:51 +08:00
SivanCola c04cbfca17 fix(desktop): normalize Windows workspace paths 2026-07-15 11:41:02 +08:00
SivanCola bed8c3f4c4 fix(agent): make the zh reasoning-language block imperative
The soft wording loses the first visible-reasoning segment on Chinese
prompts that embed English logs or code, and the first segment anchors
the whole turn once providers round-trip prior reasoning through
tool-call loops. Sampled against deepseek-v4-pro with an agent-style
English system prompt and tool schemas, the soft block held 6/9 first
segments (0/3 on the English-log shape) while the imperative block held
9/9, with no regression on deepseek-v4-flash (24/24 on both arms), and
it partially recovers mid-turn drift (0/4 -> 2/4).

Document the anchoring and drift boundary in both REASONING_LANGUAGE
docs and pin the imperative anchors with a focused test. Tag structure,
injection position, and per-turn transience are unchanged, so the
provider-visible stable prefix and old session histories are unaffected.
2026-07-15 11:35:13 +08:00
SivanCola 6ca868e0ee Merge PR #6498 hardening into feature/mcp-settings-simplification
# Conflicts:
#	internal/agent/agent.go
#	internal/agent/usecapability.go
2026-07-15 11:33:04 +08:00
SivanCola d4ebb154da fix: unify strict read-only subagent execution 2026-07-15 11:26:01 +08:00
SivanCola 024e35c231 fix(desktop): harden Windows update recovery 2026-07-15 11:23:14 +08:00
SivanCola 19ae765a20 Make todo progress guard adaptive 2026-07-15 11:22:59 +08:00
SivanCola 90d32deef5 Merge origin/main-v2 into feature/mcp-settings-simplification 2026-07-15 11:19:05 +08:00
88be1a23ce fix(plan): separate workflow from permissions
Align Plan, Standard, and Goal with the shared Permissions and Sandbox path while preserving explicit phase opt-outs, fresh MCP reviews, and cache-stable provider surfaces.

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

Co-authored-by: myipanta <[email protected]>
2026-07-15 11:18:45 +08:00
SivanCola a2cb36e819 fix: remove obsolete desktop mode helper 2026-07-15 10:52:32 +08:00
SivanCola c3ab1caf48 fix: harden Plan mode across controller rebuilds 2026-07-15 10:45:46 +08:00
SivanCola 1a45a16400 fix(agent): enforce serial todo progress 2026-07-15 10:42:57 +08:00
SivanCola 6834e2ccfe Merge pull request #6494 from SivanCola/fix/classic-pinned-section
Show pinned conversations in classic sidebar / 经典侧边栏独立显示置顶会话
2026-07-15 10:39:20 +08:00
SivanCola 82fa7c98f9 Show pinned conversations in classic sidebar 2026-07-15 09:54:01 +08:00
8c548f25d6 chore(mcp): credit shared contributors
Record the shared work integrated from PR #6033 and PR #6413 in this PR history.

Co-authored-by: HaoyueQin <[email protected]>
Co-authored-by: jiangrenhang <[email protected]>
2026-07-15 08:21:10 +08:00
SivanCola 20d8c695f1 Merge remote-tracking branch 'origin/main-v2' into HEAD
Co-authored-by: HaoyueQin <[email protected]>

# Conflicts:
#	desktop/frontend/src/components/CapabilitiesPanel.tsx
#	internal/control/controller.go
2026-07-15 08:19:43 +08:00
SivanColaandjiangrenhang 1f91f803ed fix(control): stop auto-plan re-entry after user exits plan mode
Absorb the two improvements from PR #6413 (#3271):

- When the user turns plan mode off and then denies the pending plan
  approval, suppress auto-plan for the next turn so it cannot immediately
  re-enter the mode the user just left. The suppression is one-shot.
- Make gateApprover check preApproved explicitly before any guardian
  review or prompt, so the YOLO / approved-plan-window / session-grant
  short-circuit is stated at the approval boundary instead of relying on
  the prompt path to duplicate it.

Add regression coverage for both: the exit-then-deny turn sequence, and a
session grant answering an ordinary approval with zero guardian reviews.

Co-authored-by: jiangrenhang <[email protected]>
2026-07-15 02:56:06 +08:00
SivanCola 109da0ab0b fix(desktop,mcp): close settings editor and install validation gaps
- Editing an existing server now materializes explicit clear values for
  non-secret fields the user removed from the form or advanced JSON;
  absent env/headers still preserve stored secrets.
- Reject null per-tool timeout values instead of silently clearing every
  stored timeout through an empty map.
- Validate MCP approval mode and reviewer fields at install plan time,
  before the server subprocess is ever connected.
- Cover UpdateMCPServer preserve-vs-clear semantics, align the destructive
  approval test fixture with the real i18n strings, and document the new
  plugin approval fields in reasonix.example.toml.
2026-07-15 02:53:05 +08:00
SivanCola a413c821de fix(mcp): keep installed MCP writers on the normal permission path while planning
Restore the plan-mode routing that lets installed MCP writers fall through
to the ordinary Ask/Auto/YOLO permission posture instead of a hard
planning-stage block, per maintainer decision. Built-in writers remain
Plan-blocked, PlanSafetyUnsafe remains a hard stop, and third-party
readOnlyHint still grants no Plan/planner/read-only-research trust.

Align every surface that described this boundary: the public site page no
longer claims hinted readers join plan mode, the GUIDE mode table names the
MCP-writer exception, and the GUIDE gains explicit notes on the writes-mode
classification trust boundary, legacy Guardian routing under prompt/writes,
and reviewing approve/writes policies in third-party .mcp.json files.
2026-07-15 02:53:05 +08:00
SivanCola 417335f027 Merge pull request #6434 from SivanCola/feature/claude-plugin-compat
Add Claude plugin compatibility / 增加 Claude 插件兼容支持
2026-07-15 02:41:31 +08:00
SivanCola 6c13597de1 fix(mcp): close approval and settings gaps 2026-07-15 02:31:45 +08:00
SivanCola a95466b052 Merge pull request #6438 from SivanCola/feature/acp-independent-session-axes
Expose independent ACP session axes / 拆分 ACP 会话三轴
2026-07-15 02:29:55 +08:00
SivanCola 0d5f70401b fix(plugins): polish hook adapter semantics and deflake spawn tests
- An unbounded Reasonix wait now omits TaskOutput's optional timeout
  instead of claiming 0 — in Claude's schema timeout is the maximum wait
  in ms, so 0 would read as "don't wait", the opposite of the call.
- Structural input gaps (WebFetch prompt, NotebookEdit cell_number,
  TaskOutput multi-job) are reported once per hooks file instead of once
  per hook item, so a wildcard-matcher plugin gets one warning per gap
  rather than one per hook; file-level wording also keeps the emitted
  set deterministic across the random event-map iteration order.
- Real-spawn hook tests share one generous realSpawnTimeout: node cold
  starts and first-run scans of a freshly built test binary can stall
  for seconds on a loaded machine, and these tests assert behavior, not
  latency. The timeout-path case keeps its tight budget on purpose.
2026-07-15 02:24:50 +08:00
SivanCola 9abf0a52b0 test(acp): simplify embedded factory selector 2026-07-15 02:16:40 +08:00
SivanCola e08431c90a Merge pull request #6404 from myipanta/fix/archive-double-click-race
fix(desktop): prevent double-click race on topic archive (#6312)
2026-07-15 02:05:19 +08:00
SivanCola 50083f3557 Merge remote-tracking branch 'origin/main-v2' into feature/acp-independent-session-axes 2026-07-15 02:05:05 +08:00
SivanCola d9633fda3a fix(acp): keep config rebuilds ordered and failure-atomic
Serialize delta resolution with rebuild publication, drain queued axes iteratively after failures, and restore the client's live config when a deferred apply fails.

Propagate switch-time transcript persistence failures across ACP, CLI, and serve so the outgoing controller remains active.
2026-07-15 02:04:43 +08:00
SivanCola d20634d849 Merge pull request #6432 from SivanCola/fix/pending-prompt-stale-status-wipe
fix(desktop): keep pending plan approval visible across session switches / 修复切换会话后待确认审批不显示
2026-07-15 01:56:04 +08:00
SivanCola a3b8a7a997 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-07-15 01:34:12 +08:00
SivanCola c0c93610e6 fix(plugins): complete Claude hook contract adapters 2026-07-15 01:33:36 +08:00
SivanCola 4f89c15c2e Add layered MCP approval policy 2026-07-15 01:33:31 +08:00
SivanCola 01b8d12ee2 fix(desktop): surface residual topic cleanup failures
Propagate created-at and auto-title metadata cleanup failures so the title locator survives and a retry can converge. Add fault-injection coverage for both metadata files and assert auto-title state is removed.
2026-07-15 01:31:16 +08:00
SivanCola 69ffaf7666 fix(desktop): ignore stale approval drain results 2026-07-15 01:24:44 +08:00
SivanCola 7ddd7a8b20 fix(guard): alias snapshot IDs in AI assist 2026-07-15 01:22:25 +08:00
SivanCola 0d64e3cfa0 Merge origin/main-v2 into feature/acp-independent-session-axes 2026-07-15 01:20:46 +08:00
SivanCola 5b0dfb0621 fix(guard): harden diagnostic privacy and repair commits 2026-07-15 01:12:44 +08:00
SivanCola ca989965e1 fix(desktop): delete the topic title last so retries keep a locator
Per-root cleanup removed the title entry before the fallible
title-sources cleanup. For a title-map-only topic (no sidebar index
entry) the title is the only thing that marks its root as a deletion
candidate, so a failure in the sources step stranded the sources and
created-at entries: the retry found neither an index entry nor a title,
skipped the root, and reported success with metadata still on disk.

Run the fallible sources cleanup (and the best-effort created-at and
auto-title-meta cleanup) before removing the title, and delete the
title entry last, so any failed attempt leaves the locator in place and
a retry converges on the fully-deleted state.

Cover title-only topics with a failure-then-retry regression test, and
tighten the indexed-topic retry test to assert the title survives a
failed attempt.
2026-07-15 00:53:40 +08:00
SivanCola 22aa3d35fe Merge pull request #6468 from SivanCola/feature/classic-sidebar-activity-sort
feat(desktop): refine classic sidebar sorting, previews, and topic actions / 经典侧栏:排序、预览与会话操作
2026-07-15 00:47:49 +08:00
SivanCola ef7e813f2e fix: complete Claude hook tool_input/tool_response contract translation
Verified against the Claude Code 2.1.209 tool schemas and the official
security-guidance plugin source:

- Extend tool_input key renames beyond the four file tools: notebook_edit
  path->notebook_path, run_skill/read_only_skill name/arguments->skill/args,
  bash_output/kill_shell job_id->bash_id/shell_id, and the dedicated
  subagent wrappers' task->prompt, so Claude-authored guards reading those
  fields no longer fail open on empty values.
- Synthesize Agent's prompt for parallel_tasks from its sub-task prompts
  (keeping tasks alongside), bridging the structural mismatch so an
  Agent-scoped guard inspects all dispatched work.
- Resolve relative file_path/notebook_path absolute against the payload cwd
  (the same root Reasonix file tools resolve against), matching Claude's
  "must be absolute" file-tool contract for prefix-matching guards.
- Deliver Bash tool_response in Claude's {stdout, stderr, interrupted}
  shape instead of a bare string; the security-guidance plugin's
  commit/push checks read exactly these fields and treat a non-object
  response as empty, silently passing.

web_fetch needs no translation: its url field already matches Claude's.
2026-07-15 00:45:52 +08:00
SivanCola 7127a853b3 fix: persist switched system prompt, merge pending config axes, lock drift emitters
A work-mode/model/effort switch splices the target profile's system prompt
into the carried history in memory only: the pre-switch Snapshot persists the
outgoing history, Resume/AdoptHistory never save, and session/close does not
snapshot again, so ACP switch -> close -> load (and serve/CLI restart+resume)
revived the outgoing profile's contract from disk while the session metadata
already claimed the new profile. Snapshot the adopted history right after the
controller swap lands on all three surfaces, after the swap so a failed swap
never leaves the old controller conflicting with a rewritten transcript.

pendingConfig was a single slot, so two axes changed during one active turn
both returned success and announced config_option_update, but only the last
delta survived to the post-turn drain. Queue one delta per axis
(last-write-wins within an axis only), resolve all queued axes into one state,
and apply them in a single rebuild; a rebuild claims only its own axes from
the queue so entries queued by other requests still drain afterwards.

emitModeDrift/emitToolApprovalDrift read controller state and swapped it into
the session without stateChangeMu, so a session/set_mode or tool-approval
selection completing between the read and the swap was read back as drift,
rolled the metadata back to the pre-selection value, and the next rebuild
re-applied the stale value to the replacement controller. Both emitters now
hold stateChangeMu across the read-and-swap.

Regression coverage: ACP switch -> close -> load system-prompt round-trip,
serve/CLI on-disk prompt assertions after a switch (verified failing before
the fix), per-axis pending merge during an active turn, a stale-drift-read vs
set_mode race with a pending rebuild, and a lock-contract test for both
emitters.
2026-07-15 00:44:38 +08:00
SivanCola 7515083239 fix(desktop): dismiss only backend-drained approvals on mode switch; epoch-guard submit failures
Round 4 of #6432:

- P1: switching tool approval to auto/yolo unconditionally cleared and
  tombstoned the visible approval, but the backend's drainLocked keeps
  fresh user decisions (plan/memory/sandbox escape) pending, and auto
  keeps approvals an allow policy would not cover. The tombstone then
  filtered every replay of the still-pending prompt, stranding the turn.
  SetModeForTab/SetToolApprovalModeForTab now return the prompt ids the
  switch actually auto-allowed (Controller.ApplyMode/ApplyToolApprovalMode)
  and the frontend dismisses + tombstones exactly those.

- P2: submit_prompt_failed only compared prompt ids, so a late failure
  from a pre-rebuild controller RPC could erase the rebuilt controller's
  tombstone for the same numeric id and let a delayed replay resurrect an
  already-answered prompt. approve/answerQuestion now capture the tab's
  prompt epoch at RPC start and the reducer requires {id, epoch} to match;
  controller_rebuilt and reset advance the epoch.
2026-07-15 00:41:57 +08:00
SivanCola 659cbb9c2f fix(guard): send only an allowlisted diagnostic DTO to the AI provider
The outbound sanitizer replaced four known path prefixes inside Message
and Remediation and left every other field alone. Diagnostic prose embeds
user-controlled content — TOML parse errors quote config lines, URL
errors echo the full URL including credentials, MCP findings quote the
command line, permission findings quote rules — and Scope carries
provider/plugin names, so emails, tokens, and arbitrary paths reached the
selected provider unchanged.

Replace the blocklist rewrite with a strict allowlist DTO: findings ship
only severity, machine code, and a closed-vocabulary scope (derived:*
targets survive for rebuild plans; provider:*/plugin:* collapse to their
category; anything unrecognized becomes "other"), plus snapshot IDs,
update versions, and a fixed "<project>" root. No free-form diagnostic
string leaves the machine.
2026-07-15 00:25:30 +08:00
SivanCola b6537edbc4 fix(recovery): commit repair transactions independently of the audit log
saveRepairTransaction returned the audit-log append error after
last-repair.json was already persisted. Snapshot restore treated that as
an uncommitted operation: it restored the original config and consumed
the new backup, leaving the persisted transaction pointing at a missing
file — every later UndoLastRepair failed — while the previous valid undo
had already been overwritten. UndoLastRepair itself likewise reported
failure after completing.

last-repair.json is the durable undo state and commits on persist; the
append-only audit log is best-effort and no longer fails the save.
2026-07-15 00:25:30 +08:00
SivanCola d65d7b337f Merge remote-tracking branch 'origin/main-v2' into fix/pending-prompt-stale-status-wipe
# Conflicts:
#	desktop/frontend/package.json
2026-07-15 00:24:24 +08:00
SivanCola 58eaeca179 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-07-15 00:22:03 +08:00
SivanCola 1ec2054df1 Merge main-v2 into Claude plugin compatibility 2026-07-15 00:14:52 +08:00
SivanCola 84a1e12ee9 fix(desktop): dedupe exact date in topic row title and aria-label
The row title joins the meta line with the exact date the same way the
hover card does, so week-old sessions repeated the calendar date in the
classic aria-label and the creation tooltip. Route both through the
shared projectTreeDedupedExactTime helper.
2026-07-15 00:10:18 +08:00
SivanCola 20194d525e fix(desktop): scope topic deletion cleanup to roots holding the topic
The convergence sweep read every project's title metadata with hard
errors, so an unreadable file in an unrelated project aborted the
deletion after the target root's title was already removed — recreating
the partial-delete state the sweep was meant to eliminate, with every
retry failing on the same unrelated root.

Limit detailed cleanup to candidate roots: those whose sidebar index
lists the topic, plus any root whose title map contains it. The title
probe tolerates read errors on unindexed roots (they are skipped),
while candidate roots keep failing hard so damage on a root that
actually holds the topic is surfaced instead of silently half-cleaned.
The index/tombstone update still always runs, preserving retry
convergence.

Add a multi-project regression test with broken unrelated metadata.
2026-07-15 00:08:56 +08:00
SivanCola d82a076b03 fix(desktop): address classic sidebar review findings
- Guard trashTopic with an in-flight ref so double-clicking the quick
  archive action cannot fire concurrent TrashTopic calls (same shape as
  the frontend guard in #6404; backend idempotency stays with #6404).
- Keep the pin entry out of the creation-mode context menu; pinning
  reorders the shared classic/workbench trees, and creation mode is
  meant to keep its original behavior.
- Stop the classic hover card from repeating the calendar date for
  sessions older than a week, and route the week-old activity label
  through the same locale-aware date formatter as the exact date.
2026-07-15 00:08:08 +08:00
SivanCola 6abb2d8c08 Merge origin/main-v2 into feature/acp-independent-session-axes 2026-07-15 00:07:21 +08:00
SivanCola cad8fd1e23 Merge pull request #6467 from SivanCola/fix/goal-delivery-yolo-integration
Fix Goal + Delivery + YOLO lifecycle integration / 修复目标、交付与 YOLO 组合生命周期
2026-07-14 23:56:12 +08:00
SivanCola 39133e497e fix(guard): fail closed on any incomplete failed-install rollback
The failed-installer marker means the installer may have left the release
unit partially replaced, so the live binaries cannot be presumed coherent.
The launch gate keyed on MixedInstall, which only reports whether this
rollback failed to compensate its own swaps — a rollback that failed while
staging returns MixedInstall=false while leaving the installer's
half-written unit in place, and Guard went on to start it.

Block startup on every RecoverFailedInstall error unless the restore
actually completed (RolledBack with only the marker cleanup failing).
2026-07-14 23:52:57 +08:00
SivanCola 99e12a0313 fix(recovery): make snapshot-restore cleanup cross-device safe
The failure cleanup renamed the backup over dest — but the fallback that
creates a byte-copy or recreated-link backup is entered precisely when
that rename cannot work (the state dir sits on another filesystem), so the
cleanup's retry of the same rename failed too, its error was discarded,
and the transaction-save path had already removed dest: a failed restore
could silently delete config.toml.

Restore the backup with a node-aware operation instead: rename first, then
per-type recreation (a symlink rebuilt beside dest and renamed into place,
a regular file rewritten atomically), never removing dest before the
replacement can land, consuming the backup only on success, and surfacing
a cleanup failure joined to the original error with the backup path.
2026-07-14 23:52:57 +08:00
SivanCola 3e585fc00c fix(desktop): converge topic deletion on retry after partial failure
deleteTopic keyed the whole cleanup on the title entry: once a failed
attempt had removed the title, a retry hit the idempotent-success branch
and returned nil without cleaning the title sources, created-at entry,
sidebar index, or delete tombstone, leaving a ghost topic that kept
rendering in the tree and could never be deleted.

Sweep every step independently instead: each root's title, source, and
created-at entries are removed when present, and the index/tombstone
update always runs, so a retry (or concurrent duplicate) finishes
whatever a previous attempt left behind. deleteTopicCreatedAt now skips
its save when the entry is absent so the all-roots sweep does not
rewrite untouched files, and the sweep also clears the per-topic
auto-title meta entry that deletion previously leaked.

Add failure-then-retry and leftover-state regression tests.
2026-07-14 23:32:22 +08:00
SivanCola a149b43500 Merge pull request #6470 from SivanCola/fix/bot-stop-drain-followup
Drain bot stop races and quote selection paths / 修复 bot 停止竞态、QQ 网关排空与选区路径引用
2026-07-14 23:21:39 +08:00
SivanCola b22e9e0304 test(desktop): clean up pending crash file in Safe Mode retention test
TestMain pins REASONIX_STATE_HOME for the whole test binary, so the test's
REASONIX_HOME isolation was inert and the retained crash file leaked into
the shared state dir, failing the hang-watchdog test that runs later
(hang reports do not overwrite an existing crash). Follow the sibling
tests' cleanup convention instead.
2026-07-14 23:19:19 +08:00
SivanCola 79cbebe839 fix: unblock restored delivery review baseline and non-Goal recovery submit
A pending mutation restored from a persisted delivery checkpoint uses a -1
review baseline, but HasSuccessfulReviewAfter rejected every negative index,
so fresh read/diff receipts could never satisfy final readiness and the
checkpoint never cleared. Treat a negative index as the restored-checkpoint
baseline: the mutation's touched paths are unknowable, so any successful
review-shaped receipt (read, or a git diff/status inspection) counts, matching
the -1 semantics of the complete-step and sign-off matchers.

The delivery recovery notice appears for every final_readiness outcome, but
the continue handler required ResumeGoalForTab to succeed before submitting,
turning the button into a no-op for ordinary Delivery sessions with no Goal.
Route the flow through continueDelivery: tabs without a Goal submit the
continuation directly again, Goal tabs still resume first, and a refused
resume (completed Goal) or a mid-flight tab switch drops the send.

Regression coverage: checkpoint-baseline review semantics in the evidence
ledger, an end-to-end restored-pending-mutation-to-completion agent test, and
behavior tests for every continueDelivery path.
2026-07-14 23:16:31 +08:00
SivanCola 59f04609e6 Merge pull request #6469 from SivanCola/fix/windows-shell-pasted-paths
fix(cli): support shell-escaped Windows paths / 支持 Windows Shell 转义路径
2026-07-14 23:09:52 +08:00
SivanCola 076736c92b fix(recovery): preserve symlinked config through snapshot restore and undo
RestoreConfigSnapshot read the live config through the symlink and backed
up a plain-file copy, then atomically replaced the link itself — so after
restore + undo a dotfiles-managed config.toml was permanently severed into
a regular file.

Back up by renaming the original node aside instead (with a node-preserving
cross-device fallback), mirroring the quarantine path: the restore still
materializes the snapshot as a plain file without writing through the link,
and undo recreates the original symlink exactly.
2026-07-14 23:04:39 +08:00
SivanCola b6f4180c19 fix(recovery): compensate failed release-unit rollbacks; never launch mixed installs
The rollback restored release-unit binaries one by one after hash
verification, so a failure on a later file left earlier files on the old
build and the rest on the new one — and Guard then switched to Safe Mode
and launched that mixed old-desktop/new-Guard install anyway.

Restore now stages every backup next to its target first (a copy can fail
halfway; staging leaves the live binaries untouched), then swaps via
renames only, unwinding already-swapped binaries when a later step fails
so the unit stays coherent on the new version and the retained pending
transaction can retry. Only when that unwinding itself fails does the
result report MixedInstall — and Guard now refuses to start the desktop
in that state instead of continuing into Safe Mode.
2026-07-14 23:04:39 +08:00
SivanCola b46c5f254d fix(recovery): stop Safe Mode from persisting tabs or sweeping recovery GC
A Safe Mode boot skips loading desktop-tabs.json and builds one temporary
Global tab, but the controller-build path still wrote that tab back to
desktop-tabs.json, replacing the user's saved layout with a single random
tab. Gate the tab-snapshot write — the single choke point every save
funnels through — so Safe Mode tabs stay ephemeral.

The recovery-branch GC has the same blind spot: with no tabs loaded, every
normally-open session looks closed, so idle branches the real layout still
owns would be swept to the trash. Recovery boots no longer GC.
2026-07-14 23:04:25 +08:00
SivanCola 08e7719aec fix(recovery): force all reporting off in Safe Mode
Safe Mode boots from built-in defaults and never reads the user's config,
so it cannot see a telemetry/metrics opt-out recorded there — it started
the metrics aggregator and flushed pending metrics/crash payloads with the
defaults' enabled values, silently bypassing the user's privacy choice.

Force telemetry and metrics off in the Safe Mode config, and keep the
pending metrics/crash flushers from running at all in Safe Mode: whether
to send or drop those payloads depends on the user's real preference,
which only the next normal boot can read.
2026-07-14 23:04:25 +08:00
SivanCola 80a44f0359 test(boot): pin Safe Mode tool surface across token modes
Safe Mode must never register connect_tool_source (Economy), install_source,
or the skill tools; slash_command stays registered with an empty list.
2026-07-14 23:04:25 +08:00
SivanCola 197fd4b996 Merge origin/main-v2 into feature/reasonix-guard
Resolves internal/boot/boot.go against #6461's Economy tool-source
connector: Safe Mode registers neither install_source/skill tools nor
connect_tool_source, so a recovery boot cannot re-expose skills,
commands, memory, or MCP through the connector.
2026-07-14 23:04:07 +08:00
SivanCola e201ac31ff fix(cli): keep UNC and device prefixes in Windows shell unescape
A leading double backslash (\\server\share, \\?\C:\..., \\.\...) is a
path prefix, not a shell escape pair; unescapeWindowsShellPath collapsed
it to a single backslash, so escaped backslash-UNC and extended-length
spellings never produced an existing-file candidate.
2026-07-14 22:48:07 +08:00
SivanCola 787eae7d8c fix(bot): abort qq gateway dial on stop
websocket.DialConfig dials with context.Background(), so cancelling the
adapter context could not interrupt a TCP dial or a stalled WebSocket/TLS
handshake. Stop waits on loopWG, and the conn only becomes trackable after
the dial returns, so closeConn had nothing to close and shutdown or a
runtime rebuild could block indefinitely.

Dial with cfg.DialContext(ctx) so Stop's cancellation aborts the dial
phase, and add a regression test that stops the adapter while a local
server holds the handshake stalled.
2026-07-14 22:46:23 +08:00
SivanCola dc444f8321 Merge pull request #6472 from SivanCola/fix/windows-mixed-dpi-restore
fix(desktop): keep WebView2 scale correct after restore / 修复 Windows 混合 DPI 恢复缩放
2026-07-14 22:36:44 +08:00
SivanCola bd0dc0c079 feat(mcp): simplify settings and align tool approvals 2026-07-14 22:30:41 +08:00
SivanCola 71461948e4 Merge pull request #6461 from SivanCola/feature/lean-agent-profiles
feat(agent): streamline prompts and Economy tools / 精简提示词与轻量工具面
2026-07-14 22:19:57 +08:00
SivanCola a5d5f6fc34 feat(desktop): add classic topic quick actions
Bring the workbench pin, archive, and unread-attention behavior to classic topic rows. Read topics expose the existing quick actions on hover or keyboard focus, while unread topics reserve the right edge for the blue activity dot and runtime session rows remain action-free.

Classic action focus also dismisses the delayed hover preview, and the side column reserves enough width to keep long labels clear of the two controls. Creation mode retains its existing behavior.
2026-07-14 21:53:36 +08:00
SivanCola f9ced13d5b feat(desktop): single-line classic rows with right-aligned time
Classic topic rows drop the second meta line and adopt the side-time
recipe the other layouts already use: title on the left, compact
activity time right-aligned on the same 34px line, with the workbench
swap rules for the shortcut badge and running-state dot mirrored under
a new app--classic scope. Paused/error keep their inline status pill;
waiting shows the shared amber side pill. Turns and the exact date stay
in the hover preview card and the row aria-label.

Also expose pin/unpin in the classic topic context menu: classic
sorting already ranks pinned topics first, but the only pin entry was
gated to workbench, leaving classic users no way to set it. The
now-unused time-only meta variant is removed.
2026-07-14 21:53:36 +08:00
SivanCola 582061bfda feat(desktop): codex-inspired classic sidebar density and preview
Bring three sidebar patterns from the Codex desktop app to the classic
layout so busy projects stay scannable:

- Topic rows keep only the activity time inline; turns and the exact
  date move to a hover preview card (full title, status, turns, date,
  project) rendered through a body portal so the sidebar overflow
  cannot clip it. The full string stays on the row as its aria-label.
- Each folder previews its first five topics behind a show-more /
  show-fewer toggle. Truncation pauses during search and time filters,
  and a folder auto-reveals when the active session sits in its tail.
- Empty project folders become expandable and show a faint
  "no sessions" placeholder row instead of being inert.

Workbench and creation layouts keep their existing row recipes.
2026-07-14 21:53:36 +08:00
SivanCola e5629a8e94 feat(desktop): sort classic sidebar topics by activity time
Classic layout previously rendered topics in persisted insertion order
while each row displayed its last-activity time, so the list looked
unsorted whenever an older topic was updated recently. Arrange classic
folders through the shared workbench sorter (project order preserved,
pinned topics first) so row order matches the time shown in the meta
line, and surface the existing created/updated sort criteria in the
classic filter menu. Creation mode keeps its fixed updated ordering.

The sort choice shares the projectTree:workbenchSort storage key, so
existing saved preferences keep working across layout styles.
2026-07-14 21:53:36 +08:00
SivanCola 2f9579ffe7 fix(desktop): format vendored WebView2 sources 2026-07-14 19:31:59 +08:00
SivanCola df94dc709b fix(desktop): keep WebView2 scale correct after restore 2026-07-14 19:19:55 +08:00
SivanCola aea376eaed fix(desktop): render selection reference paths as JSON strings
formatSelectionReference fenced the selection body adaptively but put
the file path in a single-backtick code span. Backticks and newlines are
legal in file names and would terminate the span early, spilling the
path into plain instruction-like text. The path is now a JSON string,
which escapes quotes, backslashes, and control characters; tests cover
backtick, CR/LF, quote, and backslash names.
2026-07-14 19:17:38 +08:00
SivanCola a891c82cb8 fix(bot/qq): close and drain the QQ gateway connection on stop
Stop only cancelled the context, but the gateway loop blocks in
websocket reads that do not honor ctx, and nothing closed the saved
connection or waited for the loop to exit. Every bot-runtime rebuild
could therefore leave a zombie goroutine holding a live QQ gateway
session that keeps receiving dispatches.

Stop now cancels, closes the tracked connection to unblock the read,
and waits for the loop. Publication of the tracked conn is refused once
ctx is cancelled so a dial racing Stop cannot leave an unreachable
connection, and RunWithRetry's documented "attempt MUST honor ctx"
contract is upheld.
2026-07-14 19:17:38 +08:00
SivanCola 50786ba7fd fix(bot): publish and consume turn cancel under one lock discipline
closeBotSessionState read state.cancel outside gw.mu while runTurn
rewrites the field under the lock on every turn, so a Stop (or session
rebuild) racing a turn between session pickup and cancel publication was
a data race. Close now snapshots and clears the cancel inside gw.mu and
invokes it outside, and runTurn refuses to publish onto a state that was
already unlinked, cancelling that turn immediately instead of leaving it
uncancellable.

Also documents the callback reentrancy contract: GatewayConfig callbacks
run on gateway-owned goroutines that Stop drains, so calling Stop from a
callback deadlocks; hosts must trigger shutdown asynchronously.

The new regression test pins the pre-publication window with -race; the
existing lifecycle tests only stopped after RunTurn had begun.
2026-07-14 19:17:37 +08:00
SivanCola acf646142e fix(recovery): restore quarantined symlinks as symlinks on undo
The repair quarantine moves the link itself aside, but the copy-based undo
followed it with ReadFile and materialized a regular file at the target,
permanently severing dotfiles-managed config links. Recreate the link via
Lstat/Readlink/Symlink (a single atomic syscall, so the crash-safe resume
contract is unchanged), and use Lstat for the preflight and redo checks so
dangling links are still treated as present.
2026-07-14 19:04:37 +08:00
SivanCola 4285eb0e3c feat(agent): let plan mode connect the workflow planning subset
planmode.Marker promises todo_write for maintaining planning state, but
Economy kept the whole workflow source fail-closed in plan mode because
it bundles complete_step. Admit the workflow source in plan mode and
narrow its installer to todo_write while planning; complete_step still
requires a fresh workflow connect outside plan mode. Document the subset
in the tool contract and cover the full plan/execute loop.
2026-07-14 19:03:38 +08:00
SivanCola 224bbfa696 fix(cli): support shell-escaped Windows pasted paths 2026-07-14 19:00:45 +08:00
SivanCola 2cfaef26d6 fix(recovery): close rollback and undo crash windows from second review
- Keep every undo backup on disk until the per-change progress record is
  persisted (restore by copy, then mark, then remove), so a crash between
  the restore and the progress write can no longer strand an unresumable
  transaction; completed-but-uncleaned steps are swept on retry
- Record a Windows installer failure from the update helper (which runs
  outside the validated install directory and must not restore binaries
  itself) and let Guard roll back the release unit on its next launch
  instead of leaving a mixed install behind a clean-exit state
- Make redo names unique per change (index + nanosecond stamp) so one undo
  touching the same target twice keeps both redo copies
- Serialize startup-state read-modify-write cycles with a cross-process
  file lock and refuse transitions against a live foreign owner, closing
  the concurrent cold-start race
- Reject file update transactions with missing backup hashes and require
  the release unit to list the primary executable; rollback errors on any
  unhashed entry instead of skipping verification
2026-07-14 18:36:53 +08:00
SivanCola 4828ef14cd Merge pull request #6454 from SivanCola/feature/add-selected-text-to-chat
Add selected chat text and workspace code to the composer / 将聊天选区与工作区代码选区添加到输入区
2026-07-14 18:31:33 +08:00
SivanCola c01967a1bc fix(agent): preserve Economy tool allowlists 2026-07-14 18:29:36 +08:00
SivanCola 8774d436ec fix(bot): drain gateway lifecycle on stop 2026-07-14 18:26:15 +08:00
SivanCola d6a07dcb92 Merge pull request #6060 from h4rk8s/jawa/reasonix-image-paste-token-upstream
fix(cli): recognize shell-escaped pasted image paths
2026-07-14 18:11:15 +08:00
SivanCola abe8d05263 fix: integrate goal delivery and yolo lifecycles 2026-07-14 18:06:02 +08:00
SivanCola bcb7fb3408 test(cli): gate POSIX source cases on Windows
Mark the remaining backslash-space source cases as POSIX-only so Windows continues to validate its native path rules without expecting shell unescaping.

Co-authored-by: SivanCola <[email protected]>
2026-07-14 18:03:01 +08:00
SivanCola 2845bbcc30 fix(recovery): harden rollback, startup counting, plan undo, and read-only paths
- Back up and restore the complete Windows/Linux release unit (desktop
  executable plus Guard/launcher siblings), verify every backup hash before
  restoring, and roll back instead of deleting the rollback metadata when the
  Linux apply fails after the Guard binary was already replaced
- Stop counting duplicate launches as crashes: StartupTracker.Begin no longer
  overwrites state while the recorded owner PID is alive, since Wails second
  instances exit through os.Exit without OnShutdown
- Record multi-action AI repair plans as one plan-level transaction so undo
  reverts the whole plan (or the applied prefix on mid-plan failure), and
  persist per-change undo progress so an interrupted undo can resume
- Skip legacy memory/session migration and cleanup-pending deletion in Safe
  Mode to preserve the recovery isolation boundary
- Load config read-only in guard diagnose/assist so documented read-only paths
  never rewrite legacy MCP tier lines on disk
2026-07-14 17:54:52 +08:00
SivanCola 5a3fb248ca test(cli): make shell path coverage platform-aware
Use target-OS path cleaning for the injected parser helper and keep POSIX shell-escape integration tests off Windows, where backslashes remain native path separators.

Co-authored-by: SivanCola <[email protected]>
2026-07-14 17:54:05 +08:00
SivanCola 623c9b64e9 Merge pull request #6463 from SivanCola/fix/classic-tabbar-empty-drag-region
fix(desktop): Make classic tabbar whitespace draggable / 经典标签栏空白区支持拖动窗口
2026-07-14 17:45:16 +08:00
SivanCola 25b77649fc docs(agent): unpin example system_prompt and correct Economy prefix wording
The example config pinned the old default system prompt, so copying it
would override the new lean built-in default; replace it with a commented
placeholder. Docs claimed the tool schema stays stable within a profile,
but in Economy each successful connect_tool_source adds schemas on the
next request; state the accurate per-connection prefix semantics in the
English and Chinese guides and the collaboration-modes doc.
2026-07-14 17:27:07 +08:00
SivanColaandSauronSkywalker 910ba55997 fix(desktop): make classic tabbar whitespace draggable
Keep interactive tab strip descendants no-drag while allowing only unused classic chrome space to drag the window on macOS and frameless Windows. Add source-level coverage for the drag-region boundary.

Refs #6251

Co-authored-by: SauronSkywalker <[email protected]>
2026-07-14 17:02:43 +08:00
SivanCola 485c16d847 fix(cli): apply full POSIX backslash semantics when unescaping pasted paths
A character whitelist can never keep up with what zsh/bash escape
(\^ and \, were already missing): in POSIX shells a backslash simply
makes the next byte literal. Drop shellEscapedPathByte and unescape any
backslash pair; a trailing backslash stays literal. The Windows branch
still skips unescaping entirely, and the whitespace detector and token
splitter already used full semantics, so this also removes an internal
inconsistency.
2026-07-14 16:56:29 +08:00
SivanCola 6c0cac8311 feat(agent): streamline prompts and Economy tool surface 2026-07-14 16:53:41 +08:00
SivanCola 234bc2622a Merge branch 'main-v2' into jawa/reasonix-image-paste-token-upstream
Resolves conflicts with the rich-input composer refactor: Composer now
derives at-token state from activeFileReferenceToken (already
escape-aware), ListDirForTab gets the unescaped directory, and the
ref-token test joins the reorganized frontend test chain.
2026-07-14 16:40:02 +08:00
SivanCola 165477f2bf feat(desktop): align frontend @ref parsing with escaped-whitespace grammar
The desktop UI kept its own whitespace-delimited @token regexes, so a
CLI-produced ref like @docs/my\ file.md truncated in menu detection and
display badges, and the composer's inline fallback inserted refs that
could not resolve on submit. Mirror internal/control/refs.go in a shared
lib/refToken helper:

- active-token detection (FileReferenceMenu, Composer) keeps escaped
  space/tab inside the token; frag is unescaped for entry matching and
  ListDir gets the real directory path
- inline insertion (pickInlineFileReference, composerPickFileEntry
  fallback) escapes whitespace; structured workspaceRef keeps the real
  unescaped path
- display badges (attachmentDisplay) capture the full escaped token and
  show the unescaped path; workspace edit-replay serialization escapes
  whitespace so the round-trip is lossless
- Windows backslash separators stay literal throughout
2026-07-14 16:30:35 +08:00
SivanCola 3baf0b3c46 feat(recovery): add offline Guard and safe mode 2026-07-14 16:27:42 +08:00
SivanCola 61cfa52995 test(desktop): pin workspace floating-menu teardown on tab/scope switch
Full-mount regression for the cross-scope routing fix: open the preview
via revealPathRequest, pop the selection toolbar with a real DOM
selection + mouseup, and right-click blank tree space, then assert a
tabId/workspaceScopeKey rerender discards both menus. Before the fix
only an incidental dir-reload identity change cleared the first two;
the blank-tree menu survived scope switches (this suite failed).
2026-07-14 16:26:35 +08:00
SivanCola 4f895c5c66 fix(desktop): discard workspace floating menus on tab/scope switch
Same defect shape as the transcript selection overlay: the selection
toolbar and tree menus capture text/paths from the previous scope while
add-to-chat routes to the tab active at click time. A keyboard tab
switch fires no mousedown/scroll/Escape, so a surviving menu would add
the old scope's selection to the new session.
2026-07-14 16:08:29 +08:00
SivanCola 0152a8ebf5 fix(desktop): keep transcript selections from routing across sessions
The selection overlay captures only text, while onAddToChat resolves the
active tab at click time — so a selection made in session A could land
in session B after a keyboard tab switch, and the overlay stayed armed
while hydratePlaceholderActive rendered the previous transcript as a
loading placeholder.

Gate the overlay off during placeholder hydration and reset the floating
action, copy menu, and Escape-dismissal memory whenever the source tab
(resetKey) changes.
2026-07-14 16:06:24 +08:00
SivanCola af54a53003 feat(control): let @ref tokens carry backslash-escaped whitespace
Closes the gap where a pasted or completed @reference to a path with
spaces inserted fine but silently failed to resolve on submit, because
the @token grammar stopped at the first space:

- parseRefTokens now treats backslash-escaped space/tab as part of the
  token (dropping the backslash); any other backslash stays literal so
  Windows separators keep their meaning
- EscapeRefPath/UnescapeRefPath are the exported inverse pair
- pastedFileRef, FileRefLine, and @-completion inserts escape whitespace
  in the refs they produce; completion's token scanner and directory
  reads understand the escaped form so descending through a spaced
  directory keeps working
- unescaped tokens parse byte-for-byte as before, so existing
  transcripts and typed refs are unaffected
2026-07-14 15:57:00 +08:00
SivanCola fde0c2fa00 Merge pull request #6456 from SivanCola/fix/cli-composer-mouse-selection
fix(cli): support mouse selection in composer
2026-07-14 15:25:13 +08:00
SivanColaandsudoviber a79909870b feat(desktop): add workspace code selections to the chat context
Integrate the workspace selection flow from PR #6098 on top of the
selected-text context mechanism:

- Selecting code in the workspace preview pops an inline Add-to-Chat
  toolbar on mouseup (the right-click menu stays as a fallback), and
  FloatingMenu portals to <body> so a transformed ancestor cannot
  displace its fixed positioning.
- Workspace selections ride the quoted-context serialization as
  {path, text} entries instead of raw composer text, so file paths are
  preserved and instruction-like comments in repository code stay
  quoted context rather than user text.
- In plan-revision mode the snippet arrives as fenced Markdown via
  request.text — the revision input only consumes text, so a structured
  block would be dropped silently; the fence outgrows embedded backtick
  runs so content cannot forge the closing marker.
- Selection badges localize at render time and show the file basename
  for code selections.

Co-authored-by: sudoviber <[email protected]>
2026-07-14 15:20:31 +08:00
SivanCola 3c4229b578 Merge pull request #6457 from SivanCola/feature/external-opener-menu
feat(desktop): add dynamic external opener menu / 添加动态外部应用打开菜单
2026-07-14 15:18:02 +08:00
SivanCola fbaaaea071 fix(cli): unescape and tokenize shell-escaped pasted paths without whitespace
Review follow-up for #6060:
- unescape shell escapes even when the pasted path has no whitespace
  (e.g. /tmp/capture\(1\).png), so the image saves instead of failing
  with a consumed paste
- split pasted text into path tokens with shell rules (escapes and
  token-leading quotes) instead of strings.Fields, so multiple escaped
  or quoted paths on one line each become an image token
- skip unescaping on Windows to preserve native backslash path
  semantics; inject GOOS so both branches are tested everywhere
2026-07-14 15:16:19 +08:00
SivanCola 149b4be5d4 Merge pull request #6458 from SivanCola/fix/plan-delivery-readiness-loop
fix(agent): Let plans reach approval before delivery readiness / 让计划先完成审批再执行交付检查
2026-07-14 15:13:28 +08:00
SivanCola 2b3a48df06 test(agent): cover plan-blocked required capability through the full loop
The prior regression test armed the loop-guard pass directly. Drive the
whole failure loop from the review finding through Run() instead: three
consecutive plan-mode-blocked attempts at a required write-capable MCP
capability arm the guard via the storm breaker, and the following
blocker report must be accepted rather than ending in FinalReadinessError.
2026-07-14 14:49:34 +08:00
SivanCola 58f2a60037 fix(desktop): reap opener processes and persist after launch
Address review feedback on the external opener menu:

- Reap launched opener processes in a background Wait so exited
  children no longer accumulate as zombies (matches the existing
  notify sender pattern).
- Launch the application before persisting the preference so a
  config-write failure cannot block the launch and a failed launch
  never becomes the saved default; the two failure paths now show
  distinct toasts.
- Cover the Linux core paths: .desktop parsing (Hidden/Type/section
  scoping), alias matching, gio launch fallback, per-terminal
  working-directory arguments, and absolute icon resolution.
2026-07-14 14:46:47 +08:00
SivanCola 525bb13bea fix(desktop): register the add-selection shortcut and dismiss the overlay on Escape
Cmd/Ctrl+L was hardcoded in TranscriptSelectionMenu, invisible to the
settings conflict check: rebinding another action onto the same combo
would fire both handlers on one keypress. Register selection.addToChat
in SHORTCUT_DEFINITIONS, drive the handler through useGlobalShortcut
(gated on overlay visibility), and derive the visible hint from the
resolved combo so it tracks rebinds.

Escape now dismisses the floating action while keeping the browser
selection; the dismissed selection stays hidden through the trailing
keyup until it changes or a new pointer gesture lands.
2026-07-14 14:40:53 +08:00
SivanCola 41d370a89e fix(cli): cover image-ref paste paths in wide repaint check
insertImageRef also replaces an active composer selection, so the
direct image paste, clipboard image, and markdown-image attach paths
need the same shouldClearWideInputChange guard as text pastes to avoid
ghost cells on Windows after replacing a wide-rune selection.
2026-07-14 14:38:39 +08:00
SivanCola 3aba71046d fix(cli): address composer selection review findings
- Keep the composer selection active across Ctrl/Cmd+V so the async
  clipboard result replaces it instead of inserting at the cursor.
- Collapse the selection to its ordered start/end on Left/Right instead
  of moving one extra character from the drag head.
- Highlight a selected newline on the trailing caret space rather than
  the previous character.
- Run the wide-input repaint check on the paste early-return paths and
  drop the ineffectual inputBeforeSelection assignments (ineffassign).
2026-07-14 14:35:34 +08:00
SivanCola a8a44928b4 fix(agent): preserve loop-guard recovery for plan-mode capability gate
The plan-mode early return in finalReadinessCheck assigned the capability
gate failure directly, skipping the loopGuardAllowsFinal() pass that lets
the model report a host-blocked required capability. A required writer
Skill/MCP blocked by plan mode itself could therefore never satisfy the
gate and the turn ended in FinalReadinessError instead of a blocker
report. Honor the loop-guard pass in the plan branch, matching the two
execution-path call sites, and add a plan+delivery+required-capability
regression test.
2026-07-14 14:23:27 +08:00
SivanCola 36632b5ccc fix(desktop): satisfy cross-platform opener lint 2026-07-14 14:05:09 +08:00
SivanCola 3449bb3216 Merge pull request #6449 from SivanCola/fix/mcp-invalid-schema-isolation
fix(mcp): Quarantine invalid tool schemas / 隔离无效 MCP 工具 Schema
2026-07-14 14:03:11 +08:00
SivanCola 8c0a693ece fix(agent): separate plan and delivery readiness gates 2026-07-14 14:01:14 +08:00
SivanCola fc5d812209 fix(cli): support mouse selection in composer
Add grapheme-aware composer hit testing, editable selection behavior, and regression coverage for wrapped, wide, multiline, clipboard, and failed-paste paths.
2026-07-14 13:55:15 +08:00
SivanCola 84ae08fc60 feat(desktop): add dynamic external opener menu 2026-07-14 13:53:29 +08:00
SivanCola 90390d803f feat(desktop): add selected text to chat context 2026-07-14 13:51:25 +08:00
SivanCola 1b48ed0a23 fix(mcp): disable schema compiler URL loader
jsonschema/v6's default compiler loader is FileLoader, so a tool schema
with a file:// $ref was resolved from local disk during validation. If
the referenced file held a valid schema, the tool passed validation and
its unresolved external ref could still 400 the whole provider request,
breaching the no-external-resources boundary this validator promises.

UseLoader(nil) keeps registered resources and the embedded metaschemas
resolvable while every other URL now fails compilation, quarantining the
tool instead.
2026-07-14 13:49:05 +08:00
SivanCola 9be6e1e8d9 fix(mcp): require object root type for tool schemas
MCP 2024-11-05 and the Anthropic/OpenAI tool contracts require a tool's
input schema root to declare type "object", but ValidateToolSchema only
checked that the root parses as a JSON object, so schemas like
{"type":"string"} or {"type":["object","null"]} still reached the
provider tool list and could 400 the whole session.

Enforce root type == "object" in ValidateToolSchema. To avoid
quarantining the many real-world tools that omit the root type entirely
(bare {} or properties-only schemas), CanonicalizeSchema now makes the
omitted root type explicit before validation, matching the existing
empty-schema normalization; explicitly declared non-object roots are
quarantined instead of silently rewritten.
2026-07-14 13:21:54 +08:00
SivanCola ed113b4909 fix(mcp): quarantine tools with invalid schemas 2026-07-14 12:17:57 +08:00
SivanCola 1ec5cbe1be Merge pull request #6394 from billycat/feature/fix-6368-tui-cjk-repaint
fix(cli): 修复 Windows 上 chooser 自由文本模式下宽字符输入的重绘问题
2026-07-14 11:12:30 +08:00
SivanCola 612b4ae89e test(cli): cover chooser free-text wide input repaint
Co-authored-by: SivanCola <[email protected]>
2026-07-14 10:55:36 +08:00
YHH b07c0e9956 fix(registry): reject bare package-name sources for kind=skill (#6444)
Control #1 (#6442) accepts any package-name-shaped source, which is right
for kind=mcp (npx -y <pkg>) but wrong for kind=skill: install_source only
reaches the npx-package branch for kind auto/mcp, so a bare name like "123"
published as a skill resolves to an MCP server, not a skill — it slipped
through the syntactic gate. Guard it in PublishSchema so a skill source must
be a SKILL.md URL or a GitHub repo path.
2026-07-14 10:49:22 +08:00
YHH 75e75cad7e feat(registry): reject unresolvable publish sources at the schema (#6442)
The publish form stored any string as `source`, so submissions like a
free-text "这个来源是自己制作的" reached the review queue only to be
rejected for being uninstallable — install_source can't classify them, so
they were dead on arrival. Mirror install_source's own acceptance
(internal/installsource/names.go: http(s) URL, git:github.com/… shorthand,
or package name) in PublishSchema and refuse anything else — free text or a
publisher-local path — at publish time, with a matching client-side pattern
hint on the form.

Bring the crash worker under test: add vitest + a validation suite and wire
typecheck + test into the deploy step so a bad schema can't ship to
crash.reasonix.io. This is the worker's first test infra.
2026-07-14 10:33:33 +08:00
SivanCola 548c97821c fix(cli,serve,desktop): keep profile prompt and session grants correct across controller rebuilds
Repo-wide sweep for PR #6438's two confirmed defect shapes found the same
bugs at every other controller-rebuild site for a model/effort/work-mode/
settings switch:

- AdoptHistory (CLI /model, /effort, /work-mode, skill-refresh; serve's
  switchModel/switchEffort) silently overwrote the freshly built
  controller's own profile-specific system prompt with the carried
  (stale) one, so the model kept following the outgoing profile's
  contract after every switch. Desktop already had its own equivalent
  fix (resumeWithFreshSystemPrompt) and needed no change here.

- Every rebuild site across CLI, serve, and desktop (SetModelForTab,
  SetEffortForTab, SetTokenModeForTab, rebuildSettingLocked) dropped
  same-session "Allow for this session" tool grants and Plan-mode
  read-only command trust, forcing the user to re-approve things
  already granted this session after any switch.

Reuses Controller.SessionAuthorizations/RestoreSessionAuthorizations
(added by the prior commit) and wires them, plus the leading-system-
message splice, into every affected rebuild site.

Cherry-picked from fix/controller-rebuild-siblings (origin/main-v2 base);
resolved one conflict in internal/cli/cli.go where this branch predates
the unrelated --dir workspace-root threading from PR #6283.
2026-07-14 10:25:40 +08:00
SivanCola b36db7e451 fix(acp): keep profile prompt, axis switches, mode drift, and grants correct across controller rebuilds
Review of PR #6438 found four bugs in the runtime-profile/model/effort
rebuild path: AdoptHistory silently overwrote the freshly built controller's
own profile-specific system prompt with the carried (stale) one; a config
switch queued while another axis was mid-rebuild resolved its full snapshot
from the not-yet-updated baseline and could roll that axis back once
applied; finish() exposed the session as idle before controller-side mode
drift (e.g. Plan auto-exit) was reconciled, letting a concurrent switch
rebuild from the stale modeID; and a rebuild dropped session-scoped
"Allow for this session" tool grants and Plan-mode read-only command trust,
contradicting the PR's own "preserving permissions" claim.

Fixes: splice the new controller's leading system message into the carried
history before AdoptHistory; replace the pendingConfig full-snapshot queue
with a per-axis delta that re-resolves against live state at apply time;
reconcile mode/tool-approval drift before finish() marks the session idle;
and add Controller.SessionAuthorizations/RestoreSessionAuthorizations to
carry approval-manager grants across the swap.
2026-07-14 10:23:52 +08:00
SivanCola f2bef3bfb7 feat(acp): expose independent session axes 2026-07-14 10:23:52 +08:00
SivanCola 78fdf7d0c8 fix(desktop): scope resolved-prompt tombstone to controller epoch and RPC success
Round-3 review of #6432 found the resolvedPromptId guard (9ed15099b) had
two regressions of its own:

- A controller rebuild (model/effort/token-mode switch) reissues
  approval/ask ids from "1" (per-controller counters, per sound.ts), but
  nothing reset resolvedPromptId/promptArrivedId/promptArrivedAt across
  the rebuild. A genuinely new prompt from the rebuilt controller reusing
  an old id was silently swallowed as a stale replay.
- clearApproval/clearAsk write the tombstone optimistically before the
  ApproveTab/AnswerQuestionForTab RPC resolves, and its rejection was
  swallowed. If the RPC actually failed, the prompt was still genuinely
  pending server-side, but any later replay of that id was now permanently
  ignored — losing the approve-failure recovery the replay design exists
  for (#3844).

Add a controller_rebuilt action (wired to onRuntimeRebuilt) that drops the
id-anchored bookkeeping per rebuilt tab, and a submit_prompt_failed action
that undoes the tombstone (and proactively requests a replay) when the
backend call actually fails.
2026-07-14 10:02:52 +08:00
SivanCola b0255bc0ec fix(plugins): translate tool_input field names and cover every Agent entry point
Fifth review round on #6434 found two more P1 gaps in the Claude hook
compat layer:

- marshalPayload forwarded Reasonix's tool-call arguments verbatim as
  tool_input. Reasonix's Read/Write/Edit/MultiEdit use a "path" key; Claude's
  own tool_input schema (confirmed against the current hooks reference docs)
  uses "file_path" for those same tools. A guard hook reading
  ".tool_input.file_path" therefore saw an empty value and could fail open
  even though its matcher correctly fired. Bash's "command" and Glob/Grep's
  "pattern"/"path" already match Claude's field names by convention, so only
  the file-path-taking tools needed a rename; NotebookEdit's cell_number
  (0-based index) vs Claude's cell_id (opaque identifier) is a deeper
  structural difference left untranslated rather than guessed at.
- Only "task" mapped to Claude's "Agent" tool. Reasonix has six more
  subagent-spawning entry points that reach PreToolUse under their own
  names: read_only_task, parallel_tasks, and the dedicated explore/research/
  review/security_review wrapper tools (BuiltinSubagentTools in
  internal/skill/tools.go — confirmed these are independently-registered
  tools, not calls routed through run_skill). A Claude plugin's "Agent"
  safety matcher silently missed all of them. All seven now map to "Agent"
  (with the legacy "Task" matcher alias preserved). Also mapped
  read_only_skill -> Skill, alongside run_skill, per the same audit.

Co-authored-by: SivanCola <[email protected]>
2026-07-14 09:55:10 +08:00
SivanCola ca2c081249 Merge pull request #6431 from SivanCola/feature/claude-cli-interactions
Align CLI interactions with Claude Code / 对齐 Claude Code CLI 交互
2026-07-14 09:49:56 +08:00
SivanCola a2821a31a7 fix(control): sync sub-agent gate on interactive approval-mode switch
The previous fix only threaded the CLI's --permission-mode into
boot.Build for the one-shot `reasonix run` path. Interactive sessions
(chat REPL, desktop, ACP) boot with no HeadlessApprovalMode and switch
modes later at runtime via SetToolApprovalMode (Shift+Tab). That call
only rebuilt the parent executor's gate (refreshInteractiveGate); the
task/read_only_task, writer-capable skill sub-agents, and planner
runner kept the gate captured once at boot construction, with no
rebuild hook. Under interactive auto, the parent correctly failed
closed on an explicit ask rule, but a task sub-agent hitting the same
rule stayed on the boot-time default (ask resolves to allow) and ran
it silently.

Replace the frozen *freshHumanHeadlessGate boot handed to every
sub-agent surface with control.SharedHeadlessGate, a mutable,
concurrency-safe holder every consumer reads through by the same
pointer. SetToolApprovalMode and ApplyHeadlessApprovalMode now call
Update on it (via the new Options.SubagentGate wiring) so a runtime
mode switch reaches sub-agents the same way it reaches the parent.
2026-07-14 09:32:26 +08:00
SivanCola 347fd46f85 fix(plugins): flag Claude hook matchers that can never fire
Fourth review round on #6434 found two P2 accuracy gaps once the two P1
security bypasses were confirmed fixed:

- claude_compat.go accepted a hook's matcher without checking whether it
  names a real Claude tool Reasonix has no equivalent for. "WebSearch"
  (no Reasonix web-search tool) and "ExitPlanMode"/"EnterPlanMode" (plan
  approval is a controller-level decision, never dispatched through the
  tool-call hook path; confirmed by tracing permissionRequestHookPayload's
  explicit exclusion of planApprovalTool) are two concrete, independently
  verified examples: a hook scoped to only these matchers would never fire
  while the package still reported "full" compatibility. A plain
  tool-name matcher, or a "|"-alternation of them, naming only tools from a
  conservative, individually-verified unsupported set now downgrades the
  package to "partial" with a structured warning; matchers using other
  regex syntax, or naming even one tool Reasonix does support, are left
  unevaluated to avoid false positives. Also fixed a real gap found while
  auditing this: "run_skill" (Reasonix's skill-invocation tool) was never
  mapped to Claude's "Skill" tool name, so a "Skill"-scoped hook silently
  never fired either — same bug shape as the task/ask fixes from the
  previous round.
- Added a regression test exercising the opts.fresh-only branch of the
  fresh-human approval fix (PlanModeReadOnlyCommandApprovalTool, which is
  fresh via requestFreshApprovalDecision without being one of
  RequiresFreshHumanApprovalTool's fixed cases) — the existing tests only
  covered tools protected through requiresFreshApprovalTool(tool).

Co-authored-by: SivanCola <[email protected]>
2026-07-14 09:30:28 +08:00
SivanCola 9ed15099b3 fix(desktop): ignore replays of an already-answered prompt id
Round-2 review of #6432 found the idle-rejection backstop was necessary
but not sufficient: it only reconciles when a fetched snapshot is
rejected as stale, but a delayed replay of an already-answered prompt
can also arrive with no snapshot rejection ever happening (idle applied
before the replay lands, or a fresh running=true/pendingPrompt=false
snapshot that never touches approval/ask). Either way turn_done's
keepPlanApproval then resurrects the zombie approval permanently.

Track the id of the most recently user-resolved approval/ask
(resolvedPromptId, set by clearApproval/clearAsk) and ignore any
approval_request/ask_request replay carrying that same id outright,
instead of re-arming it and hoping a later snapshot disproves it.
2026-07-14 09:09:18 +08:00
SivanCola ef0aa6e200 fix(control): propagate headless approval mode to sub-agent gates
ApplyHeadlessApprovalMode only rebuilt the parent executor's gate.
boot.Build separately baked a mode-unaware default gate (nil approver,
ask resolves to allow) into every headless-only surface it
constructs — task/read_only_task, writer-capable skill sub-agents,
and the planner runner — with no way to rebuild it later. Under
--permission-mode auto, an explicit ask rule correctly denied a
parent write_file call but a task sub-agent hitting the same rule was
silently allowed, bypassing the boundary auto is supposed to enforce.

Extract the mode-to-gate mapping into control.BuildHeadlessApprovalGate,
the single construction point ApplyHeadlessApprovalMode and boot.Build
both use, and thread the CLI's --permission-mode through
boot.Options.HeadlessApprovalMode so every sub-agent gate matches the
parent's contract from construction instead of only the top-level
executor getting a post-hoc rebuild.
2026-07-14 04:09:30 +08:00
SivanCola 19e25a9259 fix(plugins): stop hook auto-allow from bypassing fresh-human approval
Third review round on #6434 found a real regression from the prior fix, plus
two remaining accuracy gaps:

- [P1, security] A Claude PermissionRequest hook's "allow" decision (added in
  the previous fix) was honored unconditionally, including for tools that
  RequiresFreshHumanApprovalTool deliberately excludes from YOLO/auto-approval
  and Guardian: sandbox escapes, Reasonix config writes, and memory
  remember/forget. A broadly-matched plugin hook returning allow could
  silently rubber-stamp any of those. Auto-allow is now ignored for
  fresh-human-required tools (falls through to the normal interactive
  prompt); a hook's deny still applies universally since refusing is always
  safe to auto-honor.
- [P1, security] The Claude subagent tool was renamed "Task" -> "Agent"
  (confirmed against the current https://code.claude.com/docs/en/tools-reference);
  Reasonix's "ask" tool (AskUserQuestion's equivalent) wasn't mapped at all.
  A matcher or tool_name check written against either current name silently
  never fired. Now emits the current name (task -> Agent, ask ->
  AskUserQuestion) while still matching hooks whose matcher uses the legacy
  "Task" alias.
- [P2] Documented, at both the code (compatibilityFor) and doc level, exactly
  what "full" compatibility does and doesn't guarantee: it reflects
  successful static mapping of declared capabilities, not full runtime
  parity — updatedInput and PreToolUse's ask/defer decisions are chosen by a
  hook's stdout at call time and can't be flagged during install the way
  if/asyncRewake/Stop/SubagentStop already are.

Co-authored-by: SivanCola <[email protected]>
2026-07-14 04:06:03 +08:00
SivanCola 3703cf430c Merge pull request #6433 from SivanCola/fix/clarify-app-chrome-tabs
Clarify AppChrome tab boundaries / 增强顶部标签页视觉分隔
2026-07-14 03:57:04 +08:00
SivanCola 741fb83664 fix(plugins): map Claude tool names in hooks and close remaining deny gaps
Second review round on #6434 found two more hook security gaps and an
over-claimed compatibility status:

- imported Claude hook matchers (e.g. "Bash", "Write|Edit") and the
  tool_name field a hook script inspects were left in Reasonix's own
  vocabulary (bash, write_file, ...), so a matcher-scoped or tool_name-gated
  Claude hook silently never fired. Added a name-translation layer
  (claudeFacingToolName) applied only to PayloadFormat "claude" hooks, both
  at match time and in the emitted payload.
- UserPromptSubmit's standard exit-0 JSON block (top-level
  {"decision":"block","reason":...}, distinct from PreToolUse/
  PermissionRequest's hookSpecificOutput shape) was not parsed, so a hook
  denying a sensitive prompt via JSON instead of exit 2 was ignored.
- PermissionRequest's "allow" decision (auto-approve without prompting) was
  never implemented — only the deny half of Claude's auto-answer contract
  from the prior fix. Runner.PermissionRequest now returns a tri-state
  decision (nil/deny/allow) and the approval flow honors all three.
- claude_compat.go now parses the "if" and "asyncRewake" hook fields and a
  Stop/SubagentStop hook import, and downgrades compatibility to "partial"
  with a structured warning instead of claiming "full" for semantics
  Reasonix doesn't evaluate (conditional execution, wake-on-exit-2 async,
  and block-the-turn respectively). The hook is still imported best-effort.

An existing test (TestPlanClaudeCompatibilityReportsAgentsHooksAndMCP,
modeled on the PR's own agent-critter example) had asserted "full"
compatibility for exactly this Stop-hook case; updated to the corrected
"partial" expectation.

Co-authored-by: SivanCola <[email protected]>
2026-07-14 03:38:21 +08:00
SivanCola 8b2d513657 test(boot): pin --add-dir resolution against explicit --dir workspace root
The rebase onto #6283 folds WorkspaceRoot into cliBuildOverrides, so --dir now
sets boot.Options.WorkspaceRoot and --add-dir relative paths resolve against it.
Add a regression test proving a relative --add-dir resolves under the explicit
--dir, not the enclosing git root.
2026-07-13 19:29:58 +00:00
SivanCola 9fa57fd6ae fix(control): headless auto preserves explicit ask rules (deny, not silent-allow)
Second review round on the headless approval mode: auto and yolo shared one
nil-approver gate, so a command the config explicitly marked "ask" ran silently
under `reasonix -p --permission-mode auto`, violating auto's "preserve ask rules"
contract (interactive auto prompts on those and never auto-approves them).

Split the gates: auto uses the deny approver so an unanswerable ask decision
fails closed while the ordinary writer fallback is still auto-approved; only
yolo/bypassPermissions keeps the nil-approver bypass; dontAsk still denies both.
Document the non-interactive resolution of every mode in CLI.md / CLI.zh-CN.md,
and update the tests to assert auto denies an explicit ask rule, auto allows the
writer fallback, and yolo bypasses the ask rule.
2026-07-13 19:27:20 +00:00
SivanColaand司空祎宸 e7cb007468 fix(cli): make headless run safe and align approval/print/completion contracts
Address six review findings on the CLI interactions work:

- run: map --permission-mode onto a non-blocking headless gate instead of
  installing the interactive approver/asker. `reasonix run` has no key loop and
  the approval timeout defaults to infinite, so auto/dontAsk/yolo previously
  wedged forever on an Ask rule, the `ask` tool, or a sandbox/config approval.
  New Controller.ApplyHeadlessApprovalMode swaps the gate only (auto/yolo allow
  the writer fallback, dontAsk denies what would ask); deny rules and fresh-human
  tools stay enforced.
- permission: strip session-allow (--allowed-tools) entries that target tools
  requiring fresh human approval, so `--allowed-tools remember` can no longer
  bypass the always-prompt memory contract via the interactive gate.
- run: send the --copy notice to stderr under --print / structured output so
  `--output-format json` stays a single machine-readable object.
- routing: honor -p/--print when it trails other top-level flags, so
  `reasonix --model X -p "task"` dispatches to `run --print`.
- tui: restore the legacy numeric 4 as deny on fresh two/three-choice approvals.
- tui: handle Ctrl+P/Ctrl+N in the completion menu, matching the documented keys.

Adds focused regression tests for each fix.

Co-authored-by: 司空祎宸 <[email protected]>
2026-07-13 19:27:20 +00:00
SivanCola 03b9200cb3 fix(cli): align picker j/k search behavior 2026-07-13 19:27:20 +00:00
SivanCola a99256d523 feat(cli): align terminal interactions with Claude Code 2026-07-13 19:27:20 +00:00
SivanCola 062035a773 Merge pull request #6283 from SauronSkywalker/fix/cli-dir-set-workspace-root_20260710_113109
fix(cli): make --dir explicitly set workspace root instead of falling back to git-root detection
2026-07-14 03:20:31 +08:00
SivanCola 6e4596cc04 fix(desktop): refetch backend truth after rejecting a stale prompt snapshot
Second-round hardening for #6432. The prompt-freshness guard plus id
anchor still had a hole across a navigation boundary: on a rapid
A→B→A switch (or a single-surface state wipe), backend_activation_start
drops the prompt anchor, so a delayed replay of an already-answered
prompt is treated as a brand-new prompt and re-anchors to a late time.
The authoritative idle ListTabs result fetched right after the answer
then looks stale and is discarded, and turn_done's keepPlanApproval
resurrects the resolved exit_plan_mode card — the input locks again.

A frontend anchor alone cannot distinguish genuine recovery of a
still-pending prompt from a delayed replay of a resolved one, because
navigation can erase the anchor the distinction relies on. Add the
authoritative backstop: whenever dispatchRuntimeStatusForTab rejects a
stale idle snapshot, schedule a single debounced reconcile that refetches
backend state. If the backend resolved the prompt, the fresh snapshot
(taken after any in-flight replay) is newer than the anchor and clears
the zombie; if the prompt is genuinely pending, the fresh snapshot keeps
it and re-replays. This converts a potentially stuck zombie into one that
self-heals within STALE_PROMPT_RECONCILE_MS (150ms).

The reducer intentionally still rejects the stale snapshot (it cannot
authoritatively resolve the ambiguity in-place); the hook-level reconcile
is the authority, matching the reviewer's "reject then reconcile" option.

Tests: add a hook-level convergence case driving the exact reverse race
(snapshot fetched before the prompt, dispatched after; backend reports
idle) and asserting the scheduled reconcile clears the zombie and unlocks
input. Mutation check: disabling the reschedule fails the two convergence
assertions (zombie survives, input stays locked).
2026-07-13 19:13:17 +00:00
SivanCola e9c30ba769 fix(cli): register cwd-restore cleanup after t.TempDir() in subagent --dir test
t.Cleanup runs LIFO. The cwd-restore cleanup was registered before
t.TempDir(), so its own RemoveAll cleanup fired first while the process
cwd was still inside the temp dir being removed — Windows refuses to
delete a directory that is the process's current working directory.
Register the restore after t.TempDir() so it runs first and cwd is back
outside the temp tree before RemoveAll runs.
2026-07-14 03:12:40 +08:00
SivanCola cd8a6c2371 Merge pull request #6421 from SivanCola/fix/subagent-delivery-readiness-cascade
fix(agent): stop delivery sub-agent readiness failure cascade / 修复交付模式子 agent readiness 失败级联
2026-07-14 03:05:50 +08:00
SivanCola 019719d8b2 fix(cli): thread --dir workspace root through subagent run/try setup seam
setupSubagentCommand hardcoded an empty workspaceRoot regardless of --dir,
so subagent run/try inside a nested directory of a git repo fell back to
git-root detection and got broader access than the explicit --dir target
(e.g. --dir /a/b under /a/.git resolved to /a). Thread
workspaceRootForDir(*dir) through the seam the same way runAgent and
chatREPL already do, and fail loudly on a Getwd error instead of
silently widening scope.

Add regression coverage for the real plumbing (not just the helpers):
subagent run/try --dir now asserts the explicit root reaches the setup
seam unchanged, and a new cli-level test drives chdirTo ->
workspaceRootForDir -> setupProfile -> boot.Build to prove the initial
controller pins an explicit --dir root and still falls back to the
git root when --dir is empty.
2026-07-14 03:01:51 +08:00
SivanCola 6506ba46b7 fix(plugins): honor Claude hook deny decisions and fix hook dedup
Review on #6434 found that an imported Claude PreToolUse/PermissionRequest
hook's deny decision was silently dropped: ParseOutput only ever extracted
additionalContext from hook stdout, and PermissionRequest's exit code 2 was
explicitly downgraded to a notification instead of a block. A Claude plugin
that relies on hooks to refuse dangerous commands or permissions would show
as fully compatible while the tool call still ran.

- honor Claude's JSON deny decision (hookSpecificOutput.permissionDecision
  for PreToolUse, hookSpecificOutput.decision.behavior for PermissionRequest)
  even on exit 0, and have exit code 2 block an imported PermissionRequest
  hook the same way Claude's own contract does. Scoped to PayloadFormat
  "claude" so native Reasonix hook semantics (PermissionRequest stays
  advisory-only) are unchanged.
- wire the PermissionRequest hook synchronously into the approval flow so a
  deny preempts the prompt instead of only notifying after the fact.
- fix hook dedup (appendUniqueHook) to also compare Env, Timeout, Async, and
  Cwd, so two hooks that share a command/matcher but differ in one of those
  no longer collapse into one.
- fix Windows path-separator mismatch: ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR}
  expansion left a mixed "C:\...\pkg/bin/server" string since the manifest's
  "/"-authored suffix was never converted to the host separator.
- lint: tagged switch (QF1003) and remove two functions orphaned by this PR's
  own refactor (appendClaudeSettingsHooks, pluginNameExists).

Co-authored-by: SivanCola <[email protected]>
2026-07-14 02:58:26 +08:00
SivanCola ba11d0884b Clarify AppChrome tab boundaries with spacing, outlines, and dividers
Increase inter-tab spacing, give each themed tab a full border outline, and
render a short divider between adjacent AppChrome tabs so separate sessions are
easy to scan. The active tab also gets a stronger border-color and heavier
label weight. Drag-and-drop placement indicators still override the static
divider when reordering.

The active-tab accent underline is owned by #6416 (merged); this change composes
with it (keeps its project-accent box-shadow, adds the border/weight) rather than
redefining it.
2026-07-13 18:58:00 +00:00
SivanCola e85119c0aa fix(jobs): close background-evidence lease lifecycle gaps
Two P1s from review: a failed/cancelled turn's Reset wiped the per-turn
ledger's background lease, but nothing re-enumerated the job manager's
still-uncommitted evidence for the next turn, so a turn that answered
without calling wait/bash_output again shipped a background mutation
final-readiness never saw — including after a process restart. Run now
re-leases a session's pending background evidence at the start of every
turn via the new PendingEvidenceJobIDsForSession.

Separately, collectBackgroundEvidence recorded NoteBackgroundLease before
confirming LeaseEvidenceForSession had terminal evidence. kill_shell flips
a job's status to Killed synchronously, well before its cancelled
goroutine actually exits and flushes PublishEvidence, so a bash_output
poll landing in that window noted an empty lease and permanently deduped
away every later retry in the same turn. TryLeaseEvidenceForSession now
reports readiness explicitly, and the lease is only noted once ready.

Regression coverage: a same-process second Run without wait, a simulated
process restart, a deterministic kill/unwind race via channels, plus unit
tests for the two new Manager methods.
2026-07-14 02:51:56 +08:00
SivanCola ea212e8590 Merge pull request #6416 from JesonChou/fix/issue-6391-active-tab-indicator
fix(desktop): add accent colour underline to active tab / 选中tab底部增加主题色条
2026-07-14 02:36:19 +08:00
SivanColaand司空祎宸 c49e85669d fix(cli): fail loudly when --dir workspace root can't resolve; add tests
Review follow-up on the --dir workspace-root change:
- Replace the silent os.Getwd() fallback in runAgent/chatREPL with a
  workspaceRootForDir helper that returns an error, so a Getwd failure exits
  instead of silently reverting to git-root/default and breaking the explicit
  --dir guarantee.
- Add regression tests: resolveWorkspaceRoot honors an explicit dir inside a git
  repo and falls back to the git root when empty; workspaceRootForDir returns the
  post-chdir working directory (empty for no --dir).

Co-authored-by: 司空祎宸 <[email protected]>
2026-07-13 18:35:41 +00:00
SauronSkywalker 61602e352c fix(cli): make --dir explicitly set workspace root instead of falling back to git-root detection
The --dir flag only changed the working directory (os.Chdir) but did not
pass its value as the workspace root to boot.Build. This meant resolveWorkspaceRoot
still walked upward to find the nearest .git, overriding the user's intent.

Changes:
- setup/setupQuiet: accept workspaceRoot string, forward to boot.Options
- runAgent/chatREPL: compute absolute workspaceRoot from --dir after chdirTo
- runServe, m.buildController: pass empty/default workspaceRoot to match new signature
2026-07-13 18:32:51 +00:00
SivanCola ad11beb35c fix(desktop): anchor prompt freshness to the prompt id, not each replay
Follow-up hardening for the #6432 reverse race. The snapshot-freshness
guard compared a runtime snapshot's fetch time against the arrival time
of the last approval/ask event. Because that arrival time was refreshed
by EVERY approval_request/ask_request, a delayed replay of an
already-answered prompt (the backend snapshots the pending prompt at
replay-call time and the event crosses the Wails bridge after the user
answers) pushed the anchor forward. An authoritative idle ListTabs
result fetched right after the answer then looked stale and was
discarded, and turn_done's keepPlanApproval resurrected the resolved
exit_plan_mode card — a zombie prompt.

Anchor promptArrivedAt to the prompt id instead: a replay carrying the
same id (post-answer delayed delivery, or the #6429 re-arm after
activation) keeps the original arrival time, so any snapshot taken after
the answer is newer than the anchor and reconciles the dead prompt. Only
a genuinely new prompt id (backend ids are monotonic within a
controller) re-anchors the time, preserving the #6429 stale-snapshot
rejection for the new prompt. The anchor is dropped at hard epoch
boundaries (new user turn, unsend, cancel, tab activation, reset) so it
cannot leak a stale id across turns or controllers.

This keeps approve-failure recovery intact: an optimistic clear followed
by a same-id replay still re-arms the prompt (only the timestamp is left
untouched), so a prompt the backend still holds stays answerable.

Reducer regression tests add the reverse race in both orderings
(idle-before-turn_done and turn_done-before-idle), the new-prompt
re-anchor, approve-failure recovery, and the epoch-boundary anchor
drops. Mutation check: reverting the id anchor to the per-replay refresh
fails 5 assertions, including the two the review flagged (authoritative
idle discarded, resolved plan approval resurrected).
2026-07-13 18:26:37 +00:00
SivanCola d9aaecc654 Fix macOS accent direction and preserve active marker on keyboard focus
Two layering fixes for the active-tab accent underline:

- The macOS .app--darwin .app-chrome--tabs .tabbar__tab--active top-edge
  accent (inset 0 2px) never rendered: applyTheme always sets
  data-theme-style, so the later :root[data-theme-style] themed rule (and the
  grouped darwin rule) win and paint a bottom-edge accent on every platform.
  Revert that dead top-edge declaration to its base box-shadow; the accent
  stays a uniform bottom-edge marker owned by the themed layer.

- :root[data-theme-style] .tabbar__tab:focus-visible (higher specificity)
  replaced the whole box-shadow and dropped the active underline, so a
  keyboard-focused active tab lost its selected marker. Add
  .tabbar__tab--active:focus-visible composing the focus ring, graphite
  shadow, and project-accent underline; non-active focused tabs keep just the
  ring.

Add app-chrome-tabs contract coverage for the active underline, focus
preservation, and absence of the dead macOS top-edge accent.
2026-07-13 18:23:39 +00:00
SivanCola 5210a06a04 Merge pull request #6424 from SivanCola/fix/perf-popup-resume-and-copy
fix(desktop): performance popup resume false positives and copy reliability / 修复性能弹窗恢复期误报与复制可靠性
2026-07-14 02:19:05 +08:00
SivanCola dc3004ecab Merge pull request #6426 from SivanCola/fix/serve-transcript-autoscroll
fix(serve): keep transcript scrollable during streaming output / 修复网页版流式输出期间无法向上滚动查看历史内容
2026-07-14 02:14:32 +08:00
SivanCola 7244493264 fix(jobs): lease background evidence until the collecting turn delivers
Two correctness fixes on background mutation-evidence recovery.

P1 — evidence was permanently consumed at collection time, before the
collecting turn committed. wait/bash_output set evidenceTaken, cleared
the in-memory copy, and (since the prior commit) drained the persisted
summary — but the receipts only reach the turn ledger afterwards, and
that ledger is reset on the next user turn. So wait -> collect -> the
turn is cancelled / the provider errors / the process exits -> next turn
lost the mutation from both memory and disk, and the background change
shipped unreviewed.

Collection is now a non-consuming lease. wait/bash_output merge a copy
into the turn ledger and note the lease on it; the agent commits the
leased jobs (clearing memory and draining the persisted summary) only
after the turn reaches a final answer past its delivery gates. A failed
or cancelled turn leaves the lease uncommitted, so the next turn — or a
restart, since the disk summary survives — re-collects it. A committed
job returns empty so a re-poll after delivery does not re-demand review.
Within a turn the lease note dedupes repeat collection.

P2 — a pre-feature artifact (mutationEvidenceVersion 0) recovered as no
evidence, silently skipping review for a legacy background writer task
collected after upgrade. A missing summary only proves the mutation
state was not recorded, not that the task made no changes, so version 0
now recovers as an opaque RiskHigh mutation like any other unparseable
version. Same-version artifacts with no summary still recover as no
evidence — that build did record the state and found none.

go test ./... passes; scripts/cache-guard.sh passes; race-tested the
lease/commit and reservation paths.
2026-07-13 18:12:52 +00:00
SivanCola 7b7b511b57 fix(desktop): stop a throwing execCommand copy from wedging the Copy button
Address review of #6424: fallbackCopyText ran document.execCommand("copy")
in a try/finally with no catch. Some WebViews reject the call with
NotAllowedError instead of returning false, so the exception propagated out
of fallbackCopyText, through writeClipboardText's unguarded final return, and
rejected its Promise<boolean>. In the crash overlay's Copy handler that
rejected the await before it could restore the button — leaving it disabled,
the exact #6388 unresponsive symptom. The same throw also surfaced as an
unhandled rejection at the `void writeClipboardText(...)` transcript call site
and synchronously at Composer's direct fallbackCopyText call.

- Root fix at the owner: fallbackCopyText now catches the execCommand throw and
  returns false, so writeClipboardText honors its Promise<boolean> contract for
  every caller and the hidden textarea is still cleaned up via finally.
- Defense-in-depth on the last-resort surface: the overlay Copy handler wraps
  the write in try/catch/finally so the button always re-enables and no
  rejection escapes into the global crash handler.

Regression: writeClipboardText resolves false (never rejects) when execCommand
throws, and still tears down the textarea.
2026-07-13 18:09:43 +00:00
460f0c3e6c fix(desktop): gate resume lag on focus too and route copy through the shared clipboard helper
Address review of #6424:

- Absorb the focus-resume mechanism from #6138: shouldRecordEventLoopLagSample
  now requires both the visibility-resume and focus-resume grace windows, and
  focus/blur share visibilitychange's resetSamples so visibleSince/focusedSince
  park at +Infinity while hidden/blurred. The sampler tick also self-observes
  hidden/unfocused state, so the fix holds when a throttled tick runs before the
  transition task is delivered (the actual race), not only when the listener wins.

- Drop the absolute 30s lag cap. Suppression is driven entirely by
  visibility/focus state, never by lag magnitude, so a genuine multi-second
  main-thread freeze while visible, focused, and settled still reports.

- Route the popup Copy button through the existing writeClipboardText helper
  (async Clipboard API -> window.runtime.ClipboardSetText -> execCommand),
  the three-tier approach from #5745, instead of a private execCommand-only path.

Regression coverage exercises hidden->resume and blur->refocus with the
transition task never delivered, a 45s settled-state freeze that must still
prompt, and the native-bridge / execCommand clipboard fallbacks.

Co-authored-by: GTC2080 <[email protected]>
Co-authored-by: ttmouse <[email protected]>
2026-07-13 17:50:02 +00:00
SivanCola 673b1f3b39 fix(serve): route approval shortcuts by focusing the prompt card
The advertised Y/A/P/N/1-5/Esc approval keys were dead in the default UI
state. isPlainKey rejects key events whose target is a text entry, but the
composer is autofocused, so after an approval_request every shortcut typed
into the draft instead of resolving the prompt (pressing Y wrote "y" and left
the approval pending).

Move focus onto the approval card (a tabindex=-1 container, not the Allow
button, so Enter can't approve by accident) when it appears — but only when
the user is not mid-entry: a non-empty composer draft or any other focused
text field (e.g. session search) keeps its focus so a real keystroke is never
turned into an unintended approval. Cleanup returns focus to the composer only
when focus still sits inside the removed card. Consumed shortcuts now
preventDefault so the resolving key does not land in the refocused composer as
a stray character.

Verified with a headless-Chromium behavioral test driving the extracted real
handlers (trusted CDP keystrokes, real focus state): default-state Y/N/Esc
resolve and restore composer focus with an untouched draft; a non-empty draft
or focused search field never resolves and keeps typing; modifier chords are
ignored; turn-end cleanup restores focus and deregisters the listener.
2026-07-13 17:41:51 +00:00
SivanCola 1d5cb7e448 fix(jobs): drop legacy evidence synthesis and drain collected evidence from artifacts
Two follow-ups on background mutation-evidence recovery:

- A pre-feature task artifact (mutationEvidenceVersion 0) no longer
  recovers as a synthetic opaque high-risk mutation. That flow never
  published child receipts, so there is nothing to recover — synthesizing
  one retroactively demanded inspection and review for work no collecting
  turn ever saw, pure burden with no safety gain over the old behavior.
  An unknown NEWER version still recovers as an opaque high-risk mutation
  so downgrade coexistence on a shared state directory cannot skip review.

- TakeEvidenceForSession now rewrites the artifact meta after a
  successful take, draining the persisted mutation summary. Previously
  only the in-process evidenceTaken flag guarded duplication, so every
  restart resurrected already-collected evidence for a re-polled job id
  and re-armed review demands for work the collecting turn had already
  handled. The rewrite is best-effort under the same job lock the
  completion-time meta write already uses.
2026-07-13 17:26:46 +00:00
SivanCola cfe2b80c5c feat(plugins): add Claude plugin compatibility 2026-07-14 01:25:22 +08:00
SivanCola 25aa97e95d Merge pull request #6420 from SivanCola/feature/crash-stats-retention
feat(crash-report): D1 retention purge, ingest sentinel, graceful write degradation / 崩溃统计 D1:自动清理 + 写入哨兵 + 摄入降级
2026-07-14 01:24:54 +08:00
SivanCola 95390ad183 fix(desktop): keep live approval/ask prompts across stale runtime snapshots
Switching to a session whose plan approval (or ask) was pending could
flash the prompt and then lose it, leaving only the composer while the
sidebar still showed "waiting confirmation" (#6429, #5561, #5481).

The backend replays the pending prompt event when a detached session
runtime re-attaches during activation, but a runtime snapshot fetched
BEFORE that event (pre-attach ListTabs, activation-time tab metas) can
be dispatched AFTER it. Such a snapshot reports the tab idle with
pendingPrompt=false, so backend_status reconciliation cleared the
approval/ask from frontend state and also skipped the compensating
replay, which only fires when the snapshot itself carries
pendingPrompt=true. The prompt stayed lost until another sidebar click
issued a fresh replay after all wipes.

Fix: stamp every approval/ask event with a monotonic arrival time and
every backend_status dispatch with the fetch-initiation time of its
snapshot. The reducer ignores snapshots that predate the live prompt,
and dispatchRuntimeStatusForTab reports a surviving prompt as
foreground-running so reconcileTabRuntime does not misread the stale
snapshot as a missed turn_done and reset the session out from under
the prompt. Snapshots fetched after the event still reconcile normally,
so answered/dead prompts are cleared as before and prompt-free tabs are
untouched.

Regression tests cover the reducer invariants (stale/tie/fresh/legacy
snapshots, approval and ask, replay-refreshed arrival time), the
hook-level race (approval delivered while a ListTabs fetch is in
flight), and a source-level check that every dispatchRuntimeStatusForTab
call site passes its snapshot fetch time. The suite fails 11 assertions
with the reducer guard disabled and passes with it enabled.

Fixes #6429
2026-07-13 17:24:27 +00:00
SivanCola e36cccc177 fix(jobs): recover background mutation evidence after restart 2026-07-14 01:03:31 +08:00
SivanCola 835ad60ccf fix(tool): defer background evidence collection during plan mode
A planning turn may legitimately wait on background jobs, but merging a
finished writer task's mutation receipts into that turn's ledger arms
delivery sign-off demands (complete_step, verification, structured
review) that a read-only planning turn cannot satisfy, wedging it into
the readiness retry loop.

collectBackgroundEvidence now skips without consuming while plan mode is
active: the job keeps its published evidence and the first collection
from a normal turn merges it. The plan-mode flag reaches the leaf
builtin package through a new planmode context key mirrored by the
agent's call-context constructor (importing internal/agent from builtin
would cycle through the agent package's own tests), with a regression
test pinning the two flags in lockstep.
2026-07-13 16:57:22 +00:00
SivanCola 6b30958d83 fix(crash-report): detect stalled ingest between checks 2026-07-14 00:44:05 +08:00
SivanCola 03e0213121 fix(serve): harden prompt lifecycle, forced reveal, unpin intent, picker modality
Four defects found by adversarial re-review of the previous two commits:

- Cancelling a turn (composer Esc, stop button) stranded pending
  approval/ask cards: still rendered, buttons posting to a dead id, and
  the approval key listener attached until an unrelated later keypress
  resolved it invisibly. Prompts now register a cleanup and
  turn_started/turn_done clear them — a turn's prompts die with the
  turn. This also fixes the pre-existing stop-button strand.
- scrollDown(true) could be swallowed when a user scroll event landed
  between the call and its rAF (scroll steps run before rAF callbacks),
  leaving an approval off-screen with a live key handler. Forced scrolls
  now re-assert the pin inside the rAF and always scroll.
- During fast streaming (a chunk per frame) every frame re-bottomed the
  viewport, so a slow trackpad drag could never accumulate the 40px
  unpin distance. Detach now also triggers on wheel intent: any upward
  notch on scrollable content unpins immediately.
- The rewind picker shared keys with the composer instead of owning
  them: letters typed into the hidden draft and Enter both sent the
  draft and advanced the picker (next scope key rewound a running
  turn). The picker now runs in the capture phase and swallows the
  plain keys it owns; modifier chords, IME composition, and
  non-printable keys pass through.

showNotice is direct feedback to a user click and now always reveals.
2026-07-13 16:26:50 +00:00
SivanCola c322200540 test(agent): normalize background evidence paths 2026-07-13 23:57:52 +08:00
SivanCola 606bd1cb5f fix(agent): preserve background subagent evidence
Carry background task receipts through the job manager and merge them when the parent collects the result, keeping delivery verification and review gates effective across turns.

Restore conservative high-risk handling for opaque writes, serialize evidence-collecting tools, and make the per-session background task cap atomic.
2026-07-13 23:49:37 +08:00
SivanCola 9b0abea651 fix(serve): scope global key shortcuts away from text entry and chords
The approval key handler listened document-wide with no target or
modifier check: with the autofocused composer, typing y/a/p/n/1-5 into
the input while an approval was pending resolved it invisibly, and
Cmd+A (select all) could grant a session-wide allow. Gate approval
keys and the '/' focus shortcut on isPlainKey (no Ctrl/Cmd/Alt, target
not an input/textarea/select/contenteditable — '/' typed into the
session search no longer steals focus), and make the rewind picker
ignore modifier chords so Cmd+C cannot hit a scope key and apply a
rewind. The picker still owns plain keys regardless of focus because
double-Esc opens it from the composer.
2026-07-13 15:30:09 +00:00
SivanCola 687ecd01ff fix(crash-report): validate and revoke alert webhook 2026-07-13 23:28:28 +08:00
SivanCola e26f6060bf fix(serve): force-scroll blocking approval and ask prompts into view
Approval and AskUserQuestion cards are blocking action requests, not
stream content: with pin-aware autoscroll they could render off-screen
while the user reads history, leaving the session looking hung. Worse,
the approval key handler is document-level, so keystrokes typed into
the composer could resolve an approval the user never saw. Force
re-pin (scrollDown(true)) so these prompts always enter the viewport.
2026-07-13 15:21:35 +00:00
SivanCola 62f77973bb fix(serve): stop autoscroll from fighting user scroll during streaming
The web UI unconditionally scrolled the transcript to the bottom on every
streamed text/reasoning chunk, so scrolling up to read earlier output was
impossible while a response was streaming — the next chunk immediately
yanked the viewport back down.

Track a pinned-to-bottom state from the transcript's scroll position
(within 40px of the bottom counts as pinned). Streamed content only
auto-follows while pinned; scrolling up detaches, and returning to the
bottom re-pins. Sending a message or loading session history force-re-pins,
matching user intent. Programmatic scrolls are now instant instead of
CSS-smooth: the smooth animation both fought wheel input and reported
intermediate positions that would break pin detection.

Verified in headless Chromium against the exact code block from this page:
follow-while-pinned, detach-on-scroll-up, stay-put-while-detached,
re-pin-at-bottom, resume-following, and force-re-pin all pass.
2026-07-13 14:38:29 +00:00
SivanCola 2f92a19db5 ci(crash-report): mirror ALERT_WEBHOOK repo secret to the worker on deploy
Setting the sentinel webhook previously required local Cloudflare
credentials (wrangler login / dashboard). The deploy workflow already
holds CLOUDFLARE_API_TOKEN, so mirror the GitHub repo secret into the
worker after each deploy instead; when the repo secret is unset the
step is a no-op and sentinel alerts stay log-only.
2026-07-13 14:02:33 +00:00
SivanCola bf5649c515 fix(desktop): ignore suspended-timer delay at visibility resume and harden popup copy
The event-loop lag sampler trusted visibleSince whenever a tick observed a
visible view, but WebKit can run overdue timer callbacks before the queued
visibilitychange task when the view is shown again. The first tick after
resume then attributed the whole hidden-view throttling delay to the
previous visible period and painted a performance.lag popup (#6419, #5909).
The sampler now tracks hidden observations itself, restarts the visible
window on the first visible tick, and drops suspension-scale (>=30s)
readings outright.

The popup Copy button was fire-and-forget and could fail silently in
WebViews (#6388): it now awaits the clipboard write, falls back to
execCommand, and reports copied/failed states. A failed Send re-enables
with the failure reason as tooltip so it can be retried.
2026-07-13 13:38:07 +00:00
SivanCola eb443a3ab1 fix(agent): stop delivery sub-agent readiness failure cascade
A delivery-mode background sub-agent that finished its edits but could
not complete the sign-off protocol hard-failed the whole job, discarded
its answer, and pushed the parent into spawning repair tasks for work
that had already landed. A user asking for one content change watched
the model open nine background "fix" tasks that all died on
"final-answer readiness failed".

Four coordinated repairs:

- Salvage readiness-exhausted sub-agent runs as explicitly unverified
  answers when a real successful mutation landed. Unbacked "done"
  claims (no mutation) and report-required review sub-agents keep
  failing. Child receipts still merge into the parent ledger, so the
  parent's own delivery gates keep requiring verification and review.
- Defer structured review (review_report / security_review) to the
  parent inside sub-agents: the child may not even hold the review
  tools, and its mutations already merge upward for parent review.
  The light post-mutation review (read the file / git diff) still
  applies inside sub-agents.
- Classify path-less (opaque) mutations from ordinary tools as medium
  risk instead of high: a bash content edit should demand a review, not
  the full security_review protocol. Privileged/opaque tools, sensitive
  paths, and 10+ file changes still classify high.
- Cap concurrent background writer tasks at 3 per session: they all
  mutate the same workspace with no isolation, and unbounded fan-out is
  the fuel of the repair-task cascade. Foreground tasks are uncapped.

go test ./... passes; scripts/cache-guard.sh passes.
2026-07-13 13:19:04 +00:00
SivanCola 7068d55763 feat(crash-report): ingest write sentinel and graceful D1 degradation
The 2026-07-03 D1 size-cap outage went unnoticed for ten days: clients
swallow ping failures by design, ingest handlers surfaced raw worker
exceptions, and nothing watched the write path.

- Wrap the D1 writes in /v1/ping, /v1/metrics and /v1/report so a
  storage failure returns a deliberate 503 with a contextual log
  instead of an opaque 500 (Cloudflare error 1101).
- Add a sentinel cron (4x/day, hours with guaranteed traffic) that
  probes a canary write into pings (removed in the same run) and
  today's real ping count, covering both write-path failures and
  upstream ingest blackouts. Alerts post to the optional ALERT_WEBHOOK
  secret (Feishu/Lark native payload, Slack-style {text} otherwise) and
  always land in the worker logs.
2026-07-13 12:59:46 +00:00
SivanCola 2cb3431211 feat(crash-report): purge expired stats rows on a daily cron
The reasonix-crash D1 database grew until it hit the size cap on
2026-07-03; from then on every ingest write (/v1/ping, /v1/metrics,
/v1/report) threw and the /stats dashboard flatlined while reads kept
working. Add a scheduled trigger that deletes time-series rows past the
dashboard's query horizon so the database plateaus:

- pings, metric_users: keep 30 days (queries read at most -29 day)
- metrics: keep 60 days (the 30d view's previous-window delta reads
  back to -59 day)
- reports/groups: untouched — triage queue and regression baseline,
  growth already bounded by per-group sampling

Deletes run in 10k-row rowid chunks with a per-run backstop so a run
never holds one giant transaction but still drains a large backlog
after an ingest outage.
2026-07-13 12:38:13 +00:00
JesonChou 0cbc898c57 fix(desktop): add accent colour underline to active tab / 选中tab底部增加主题色条
Use box-shadow inset instead of ::after pseudo-elements to avoid
conflicting with .tabbar__tab--drop-after::after drag-drop indicator.
The accent bar colour respects per-project accent colours via
--project-accent fallback chain.

Fixes #6391

Cache-impact: none — pure CSS.
Build: pnpm --dir desktop/frontend build passes.
2026-07-13 20:27:51 +08:00
SivanCola afdcd161fe Merge pull request #6409 from SivanCola/fix/verification-write-output-flags
fix(evidence): reject file-writing test-runner flags in verification classifier / 验证分类器拒绝会写文件的测试运行器参数
2026-07-13 17:36:07 +08:00
SivanCola 2f623a61a3 fix(evidence): cover jest output files and mypy report flags
jest/vitest --outputFile (and vitest's dotted --outputFile.json form)
write result files but were classified as non-mutating verification
through the npm/yarn wrappers, and every mypy --<type>-report flag
(txt, html, xml, cobertura-xml, ...) writes a report directory while
only --junit-xml was covered.

Add outputfile and pytest-reportlog's report-log to the global
write-output flags with a dotted-form fallback, and give mypy a
runner-specific check that rejects any --*-report flag so future mypy
report types stay covered without denylist updates. Read-only flags
(npm test -- --json, mypy --strict) remain verification.
2026-07-13 09:22:05 +00:00
SivanCola d4f58bbdae fix(evidence): normalize test. prefix and cover go test output channels
go test flags accept an optional test. prefix that the go tool passes
through to the test binary, so -test.cpuprofile=cpu.out bypassed both
the global write-output flags and the go-branch check while still
writing the profile. Go 1.26 also has explicit output channels the
classifier missed: -artifacts saves test artifacts under -outputdir,
and -test.testlogfile/-test.gocoverdir write log and coverage files
(-args -test.testlogfile=<path> creates the file even on success).

Strip the optional test. prefix in both matchers, add
testlogfile/gocoverdir to the global write-output flags for wrapper
passthrough, and extend the go-branch list with artifacts plus the
profile family so it stays self-contained. Read-only test.-prefixed
flags (-test.v, -test.run) remain verification.
2026-07-13 09:06:15 +00:00
SivanCola e2e6b7b8e2 fix(evidence): cover remaining go test write flags
go test -blockprofile/-mutexprofile write profiles, -trace writes an
execution trace, and -c/-o emit the test binary; all were still
classified as non-mutating verification. Add blockprofile/mutexprofile
to the global write-output flags and handle -c/-o/-trace in the go
branch only, because the dash-stripped global match would also hit
node -c (a syntax-only check) and pytest --trace (a read-only debugger
flag). Add mutation regressions for each flag and verification
regressions for the read-only look-alikes.
2026-07-13 08:52:35 +00:00
SivanCola 85e92996ad Merge pull request #6407 from SivanCola/fix/delivery-readiness-verification
fix(delivery): align sign-off verification with final readiness / 修复交付验证与最终检查不一致
2026-07-13 16:46:07 +08:00
SivanCola 08bad68d5e fix(evidence): reject node test output flags 2026-07-13 16:35:56 +08:00
SivanCola fd7aa42c79 fix(evidence): classify node rerun state as mutation 2026-07-13 16:29:53 +08:00
SivanCola c4bc768b9f fix(evidence): reject file-writing test-runner flags in verification classifier
Trusted test runners were classified as non-mutating verification even
when invoked with flags that write files: pytest/syrupy
--snapshot-update rewrites checked-in snapshot fixtures (the same class
as the globally rejected --update/-u/--fix), and --junitxml/--junit-xml,
gotestsum --junitfile/--jsonfile, and go test
-coverprofile/-cpuprofile/-memprofile write explicit report or profile
paths. Files produced by such runs bypassed the delivery
re-review/re-verify/sign-off gate because the receipts carried
Mutation=false.

Add a write-output flag guard next to the existing --fix/--update guard,
matching whole flag names in both --flag=value and space-separated
forms. Plain runner invocations remain verification commands.
2026-07-13 08:25:24 +00:00
SivanCola 60f427bd36 fix(evidence): reject file-writing node --test flags in verification classifier
--test-update-snapshots rewrites checked-in snapshot fixtures (the same
class as the globally rejected --update/-u/--fix) and
--test-reporter-destination can write arbitrary paths. Both were
classified as non-mutating verification, so files they produce would
bypass the delivery re-review/re-verify/sign-off gate. Fail closed on
these flags while keeping plain node --test as verification, and add
mutation and verification regressions for both argument forms.
2026-07-13 08:08:24 +00:00
SivanCola 404ee64acd Merge pull request #6406 from SivanCola/fix/live-cache-average-sync
fix(desktop): keep live cache averages in sync / 修复实时缓存平均命中率同步
2026-07-13 16:07:30 +08:00
SivanCola 8c98922c04 Merge pull request #6405 from SivanCola/fix/plugin-marketplace-preflight
fix(plugin): support marketplace preflight without Windows console flashes / 支持市场预检并消除 Windows 黑框
2026-07-13 15:55:23 +08:00
SivanCola 0278a11d7f fix(evidence): keep node -C (--conditions) opaque in verification classifier
Node CLI flags are case-sensitive: -c/--check is the syntax-only check
mode, while -C/--conditions executes the target script with custom
export conditions. The classifier lowercased the first flag, so
'node -C production server.js' was accepted as a delivery verification
and stopped counting as a mutation. Match flags exactly and add a
regression test proving -C stays an opaque mutation.
2026-07-13 07:46:46 +00:00
SivanCola eb74b735a5 fix(desktop): align live cache average sources 2026-07-13 15:43:41 +08:00
SivanCola dec51ed7aa fix(bot): hide rg console window in desktop bot project search
The desktop app hosts bot bridges in the GUI process; the project
search rg subprocess was the one remaining child in that path spawned
without the hidden-window flags, flashing a console on Windows.
2026-07-13 07:40:04 +00:00
SivanCola 050ec0962d fix(plugin): accept bare marketplace paths and skip unsupported sources
Real Claude marketplaces spell relative plugin sources both as
./plugins/example and as the bare plugins/example; the preflight now
accepts both (absolute and drive-qualified paths are still rejected
before the join, and escape attempts still fail the plan loudly).

Entries the subset cannot install — object sources, external URLs, and
invalid plugin names — no longer fail the whole marketplace plan: bulk
installs skip them with a per-entry warning, while selecting such an
entry by name reports a precise error. Names are validated at plan time
so broken entries surface in the preview instead of mid-apply.

Also pins the planId approval contract for marketplaces: the preview
planId must match the planId recomputed by the apply call.
2026-07-13 07:40:04 +00:00
SivanCola b3fbc1d25c fix(delivery): align sign-off verification 2026-07-13 15:31:43 +08:00
SivanCola 99e1790863 fix(desktop): keep live cache averages in sync 2026-07-13 15:11:53 +08:00
SivanCola 0f4538053c fix(plugin): support marketplace roots without console flashes
Plan and install relative-path Claude marketplace entries from one pinned GitHub snapshot. Route plugin Git subprocesses through the Windows hidden-window helper and cover marketplace selection, validation, cleanup, and real install behavior.
2026-07-13 15:02:41 +08:00
jiangrenhang 8edd8210b1 fix(desktop): prevent double-click race on topic archive (#6312)
Two-layer defense against the archive double-click race that caused
'topic not found' errors, rendering loops, and session corruption:

1. Frontend (ProjectTree.tsx): add trashingRef guard — subsequent
   clicks are silently ignored while a trash operation is in flight,
   using the same pattern as creatingRef for topic creation.

2. Backend (tabs.go): make deleteTopic idempotent — when the topic
   is already removed (e.g. by a concurrent trash), return nil
   instead of an error. The desired end state is already achieved.

Closes #6312
2026-07-13 14:57:39 +08:00
SivanCola 2d03fde248 Merge pull request #6365 from J3y0r/fix/issue-6359-turn-actions-duplicate
fix(desktop): repaint creation transcript after turn-actions removal
2026-07-13 14:42:01 +08:00
SivanCola 4bab8b4058 fix(desktop): make Creation repaint rule effective
Place the opaque backdrop on the Creation scrollbar selector so it wins the light-theme background cascade while preserving the custom scrollbar. Tighten the regression test to assert the effective rule block.
2026-07-13 14:33:23 +08:00
SivanCola 91bc626ff0 fix(desktop): drop dead light-theme canvas color and assert effective rule
The light themes already paint the creation transcript with an opaque
canvas gradient at higher specificity, so the added
--creation-canvas-color never took effect there; the WebView2
stale-pixel ghosting (#6359) only reproduces where the transcript was
transparent, i.e. dark themes. Use the matching opaque var(--bg)
directly and remove the unused variable definitions. Tighten the
regression test to assert the effective rule block plus the dark
canvas invariant it relies on.
2026-07-13 06:24:32 +00:00
SivanCola edf8b40036 Merge branch 'main-v2' into fix/issue-6359-turn-actions-duplicate 2026-07-13 06:22:06 +00:00
SivanCola 11b3325b7a Merge pull request #6400 from Li-Charles-One/feat/creation-text-size-scale
fix(creation): scale fixed font sizes with interface text size
2026-07-13 14:07:54 +08:00
SivanCola 3c49255ec0 Merge pull request #6399 from SivanCola/fix/composer-resized-input-height
fix(desktop): resized composer input collapses to a single line / 修复调整过高度的输入框固定只显示一行
2026-07-13 14:06:14 +08:00
SivanCola 7290eabc17 docs(creation): explain why context-ring popover is unprefixed but creation-only
Add comments in styles.css and Composer.tsx clarifying that .context-ring-popover
is portaled to <body> (no .app--creation prefix) yet only rendered in creation
layout, and that its font sizes intentionally use calc(px * var(--font-scale)).
Prevents future edits from reverting to bare px or assuming the rule is global.
2026-07-13 05:42:45 +00:00
SivanCola bc8c7a916b fix(creation): scale context-ring popover font sizes with interface text size
The context-window ring hover popover is portaled to <body> and is not
prefixed with .app--creation, so it was missed by the creation font-scale
sweep and kept hardcoded px sizes. Scale its font sizes with --font-scale
for parity, and add contract assertions so the portaled popover cannot
regress to bare px.
2026-07-13 05:01:10 +00:00
SivanCola 8b8a79843b Merge pull request #6384 from SivanCola/feature/setup-provider-manager
Make reasonix setup a provider manager / 将 reasonix setup 改为供应商配置中心
2026-07-13 12:28:46 +08:00
Hey_Charles ddd6436ed3 fix(creation): scale fixed font sizes with interface text size
Creation CSS hardcoded many font-size px values, so settings text size
had no effect while classic/workbench scaled via --font-scale.

Wrap those sizes in calc(Npx * var(--font-scale)) so default stays
identical (scale=1) and small/large presets work. Keep font-size:0
hide-text tricks. Add contract tests for assistant body and composer.

Fixes #5142
2026-07-13 12:22:12 +08:00
SivanCola fcfc69c0b8 fix(desktop): restore resized composer input fill height
Commit 19d580ccd changed .composer-card--resized .composer__input from
height: 100% to height: auto. JS applies no inline textarea height in
resized mode, so the rows=1 textarea collapsed to a single visible line
regardless of the card height the user chose, and multiline input
scrolled inside that one line. Any user with a persisted composerHeight
(anyone who ever dragged the composer resize handle) hit this on every
launch of the latest release.

Restore height: 100% so the input fills the user's fixed card height and
scrolls internally, keep the resized rich input on the same
fill-and-scroll contract, and add a CSS contract test locking both
declarations.
2026-07-13 12:21:37 +08:00
SivanCola 79866b537e fix(cli): keep setup default_model usable after provider add/edit
The staged setup manager only added the provider, access entry, and
credential; a first run that configured a custom provider kept the
built-in default_model (deepseek-flash) whose key was never entered, so
boot still failed with a missing DEEPSEEK_API_KEY. The old wizard
pointed default_model at the user's chosen provider.

- After adding providers, promote the first usable new provider to
  default_model when the current default cannot run (unresolvable, or
  its key neither stored nor staged). A usable default is never moved.
- When an edit/model refresh drops the exact model a "provider/model"
  default ref names, re-point the ref at the provider's own default,
  mirroring the repair RemoveProvider already performs.

Both paths go through setDefaultModel so the change replays under the
commit-time conflict check.
2026-07-13 03:57:48 +00:00
SivanCola 51ef3dd680 fix(cli): rebase setup changes at commit time 2026-07-13 10:48:14 +08:00
dev.billycat 448a43a019 fix(cli): 修复 Windows 上 chooser 自由文本模式下宽字符输入的重绘问题
c146456d 添加的宽字符重绘修复(tea.ClearScreen)仅覆盖了
chatTUI.update() 底部的主输入路径。chooser 的 "Type something"
自由文本模式有自己的输入处理分支,该分支提前返回,完全绕过了
这一检查。

在 typing 分支中捕获 beforeInput,并应用相同的
shouldClearWideInputChange/tea.ClearScreen 逻辑,从而使输入到
自定义 Ask 答案中的中文、日文及其他宽字符也能在 Windows 上触发
完整的终端重绘。

修复 https://github.com/esengine/DeepSeek-Reasonix/issues/6368

│ fix(cli): repaint wide-character input in chooser free-text mode on Windows
│
│ The wide-character repaint fix (tea.ClearScreen) added in c146456d only
│ covered the normal input path at the bottom of chatTUI.update(). The
│ chooser's "Type something" free-text mode has its own input-handling
│ branch that returns early, bypassing that check entirely.
│
│ Capture beforeInput in the typing branch and apply the same
│ shouldClearWideInputChange/tea.ClearScreen logic there, so Chinese,
│ Japanese, and other wide characters typed into a custom Ask answer
│ also trigger a full terminal repaint on Windows.
│
│ Fixes #6368
2026-07-13 10:44:22 +08:00
SivanCola a7159246cd fix(cli): remove superseded setup wizard 2026-07-13 04:43:17 +08:00
SivanCola a5e0fcd4fc feat(cli): make setup a provider manager 2026-07-13 04:37:13 +08:00
SivanCola 0df0b611b3 Merge pull request #6292 from Li-Charles-One/feat/creation-sidebar-align-polish
feat(creation): polish sidebar/dock alignment and chat scrollbar
2026-07-13 04:35:37 +08:00
SivanCola 60fd8885cb Merge main-v2 into creation sidebar polish 2026-07-13 04:20:08 +08:00
SivanCola 11b576e4c0 fix(creation): stabilize layout defaults and scrollbar
Apply style-specific unsaved layout defaults only after desktop preference hydration, preserve saved dimensions, and observe dynamic transcript content so the custom scrollbar stays accurate.

Co-authored-by: SivanCola <[email protected]>
2026-07-13 04:11:27 +08:00
SivanCola c4bcf3e09b Merge pull request #6287 from Li-Charles-One/feat/creation-workspace-tree-chrome-polish
feat(creation): polish workspace dock changes view
2026-07-13 04:04:07 +08:00
SivanCola f4f82e2832 Merge pull request #6382 from SivanCola/fix/delivery-mode-hardening
Harden delivery-mode review protocol, gating, and streaming liveness / 加固交付模式 review 协议、门槛判定与流式活性
2026-07-13 03:54:12 +08:00
SivanCola 481d0f4e2a Merge main-v2 and preserve workspace session isolation
Keep the Creation workspace dock polish while integrating tab-scoped file APIs, workspaceScopeKey request isolation, and the latest regression coverage.

Co-authored-by: SivanCola <[email protected]>
2026-07-13 03:51:42 +08:00
SivanCola f3029e35ed fix(delivery): align desktop marker test with exact-cut rule; trusted task text for parallel_tasks
Second review round:

- The desktop topic-title regression test built its message from a
  paraphrased delivery-runtime marker, which the byte-exact suffix cut
  correctly refuses to strip — the desktop CI job failed on it. The marker
  constant is now exported (agent.DeliveryRuntimeMarker) as the single
  source of truth; the test builds from it, and the exact-match contract
  stays intact. Its text is byte-identical since introduction, so the exact
  cut is safe for all persisted sessions.
- parallel_tasks children now pass the pristine task through
  Options.ClassifierTaskText before workspace wrapping, matching
  runSubSession. Without it the wrapped text hit the IsTask length fallback
  and a conversational child exhausted final-answer readiness instead of
  answering; regression test added (fails on the previous code).
2026-07-13 03:45:18 +08:00
SivanCola e3052d3ce3 fix(delivery): address review — trusted classifier channel, exact marker cut, id-less partials
Review follow-up for the three P2 regressions:

- Delivery intent classification no longer pattern-strips host framing from
  the raw input (user input dressed up as <workspace-context> could disarm
  the gates). Sub-agent spawners now pass the pristine task text through
  Options.ClassifierTaskText — a host-trusted channel captured before any
  wrapping — and top-level turns are classified verbatim, restoring the
  pre-PR trust boundary. Spoof-resistance regression test added.
- The trailing <delivery-runtime> strip is a byte-exact CutSuffix of the
  host marker instead of a lazy $-anchored regex, which could swallow user
  prose between a literal marker mention and the real suffix. Inline-mention
  preservation test added.
- Partial tool dispatches without a call ID (OpenAI-compatible streams can
  surface the name first) no longer create a synthetic-id card that the full
  dispatch can never merge with; arg progress still feeds the composer pill
  and the card appears once the real ID arrives. Orphan-card regression test
  added.
2026-07-13 03:25:11 +08:00
SivanCola 9d1523e7fe Merge pull request #6375 from SivanCola/fix/transcript-selection-context-menu
fix(desktop): add right-click copy menu for selected transcript text / 修复转录区选中文本右键无复制菜单
2026-07-13 03:22:56 +08:00
SivanCola 1c60eb1e18 refactor(desktop): route message intersection through the shared range helper
rangeIntersectsMessage still called Range.intersectsNode directly and
silently returned false where the API is missing, while the menu gate
already had the compareBoundaryPoints fallback. Both paths now use the
same rangeIntersectsNode helper, so the fallback story is uniform.
2026-07-12 19:16:56 +00:00
SivanCola fdef0f5b41 fix(delivery): harden review_report protocol, read-only gating, and streaming liveness
Delivery-mode hardening from a full-workflow field trace (16-min screen
recording + persisted event log of the same session):

- review_report protocol: append an explicit submission contract to review
  subagent task prompts, nudge a finished-but-reportless review subagent on
  the same session (evidence preserved so the report can cite earlier reads)
  up to 2 times before failing, dump the failed transcript for diagnosis,
  and correct the error text that told the parent to call a tool it does
  not have.
- delivery intent classification: classify mutation/work expectations from
  the user task text only — host framing (subagent/workspace context blocks,
  delivery-runtime marker) contained incidental verbs ("resolve") that
  classified every wrapped subagent prompt as a mutation request; read-only
  registries can never satisfy a state-change expectation, so it is no
  longer armed for them (this deadlocked read_only_task reviews).
- final readiness budget: extend nudges past the base cap (3, up to 6) only
  while the model keeps earning new host-observable receipts, so a
  converging turn (fix -> verify -> re-review) is not cut off mid-recovery
  while a stalled turn still fails fast.
- title/preview hygiene: strip the trailing <delivery-runtime> marker in
  StripTransientUserBlocks, share one synthetic-user filter
  (agent.SyntheticUserPrefixes / IsUserAuthoredTurn) across session preview,
  meta turn counts, and desktop topic auto-titles so host-injected readiness
  nudges can no longer become titles ("你是谁? <delivery-run…") or inflate
  user-turn counts.
- streaming liveness: providers emit ChunkToolCallArgsDelta progress (per
  2KB) while tool-call arguments stream; the agent re-emits throttled
  partial ToolDispatch events with ArgChars; the desktop upserts a live
  "receiving arguments" tool card instead of dropping partial dispatches,
  and the composer pill folds streamed arg chars into its token estimate —
  a 30KB write_file body no longer looks like a hang for 80 seconds.
- right-panel freshness: a source-agnostic usageSeq keeps 会话指标/用量分析
  refreshing during sub-agent runs; the context gauge no longer collapses
  to 0/1M when a mid-turn snapshot races a rebuilt executor.

Verification: go test ./... (root and desktop modules), scripts/
cache-guard.sh (tail_avg 92/95/95 >= 90), desktop/frontend pnpm typecheck +
check:css + test:all, plus new regression tests covering every item above.
2026-07-13 02:59:39 +08:00
SivanCola f8a0a3c3e9 fix(desktop): require the right-clicked message to intersect the selection
Landing inside any message body was not enough: selecting message A and
right-clicking message B still offered Copy, which would copy A's text
rather than what sits under the click. The menu gate now requires the
target's message node to intersect a selection range (intersectsNode,
with a compareBoundaryPoints fallback for DOM implementations without
it), so a selection spanning several messages accepts a right-click on
any of them while single-message selections only answer clicks on their
own message. Adds the select-A/right-click-B and cross-message-span
regression cases.
2026-07-12 18:47:36 +00:00
SivanCola 1a01eba4a1 fix(desktop): scope transcript copy menu to right-clicks inside message bodies
A message selection outlives clicks elsewhere, so the menu-only gate must
also check where the right-click lands: without it, right-clicking the
project tree or tab bar while a transcript selection survived opened the
transcript Copy menu on top of those surfaces' own context menus.
messageSelectionContextText now additionally requires the event target to
sit inside a message or reasoning body (same selector the selection gate
uses); ⌘C interception is unchanged. Adds the selection-inside/target-
outside regression case.
2026-07-12 18:33:22 +00:00
SivanCola 28b8df8c3f fix(desktop): add right-click copy menu for selected transcript text
The Wails shell suppresses the webview's default context menu (main.tsx)
because its Reload/Back/Inspect entries can navigate away from the app,
which also killed the native Copy menu for selected message text — ⌘C
worked but right-click showed nothing.

Mount a document-level TranscriptSelectionMenu that opens the app-drawn
ContextMenu with a Copy item whenever a non-collapsed, non-editable
selection touches a message or reasoning body — the exact gates the ⌘C
interceptor already uses (messageSelectionContextText). Copy writes
through navigator.clipboard, then the Wails ClipboardSetText bridge,
then the execCommand fallback extracted from Composer into lib/clipboard.
The listener stays inert in a plain browser, where the native selection
menu still opens; editable targets keep their native menu.
2026-07-12 18:19:49 +00:00
SivanCola f4c9bcfd3b Merge pull request #6374 from SivanCola/feature/perf-longtask-attribution
Attribute performance reports via JS self-profiling and denoise long-task prompts / 性能报告接入采样归因并为长任务提示降噪
2026-07-13 01:50:57 +08:00
SivanCola 2507c780e8 style(desktop): gofmt main.go 2026-07-13 01:33:15 +08:00
SivanCola 7bdcdb52e7 feat(desktop): attribute performance reports and denoise long-task prompts
Production telemetry shows performance-pressure reports dominating crash
triage (~18k in 30 days) while carrying no actionable stacks, and the
cumulative long-task rule fires on routine streaming-render bursts.

- Sample JS stacks with the Self-Profiling API: a rolling 10ms/60s
  profiler is stopped when a performance prompt fires, and samples that
  landed inside recorded long-task windows (plus the lag spike itself
  for event-loop reports) are aggregated into a leaf-frame self-time
  top list, reported as "long task top frames (sampled)".
- Serve Document-Policy: js-profiling from the Wails asset server so
  Chromium WebViews (WebView2) expose the Profiler API; WebKit ignores
  both the header and the API and keeps the unattributed report shape.
- Record longtask culprit attribution, surfacing only cross-context
  anomalies (self/window culprits carry no signal).
- Raise the cumulative long-task prompt budget from 1.5s to 3s per 60s
  window; extract shouldPromptForLongTasks for direct coverage.
- Mention sampled app function names in the report privacy note.
2026-07-13 00:59:30 +08:00
SivanCola 20a64b4d15 Merge pull request #6373 from SivanCola/fix/opaque-review-coverage-guidance
Improve review recovery, stable notice localization, and readiness UX / 优化审查恢复、通知本地化与交付检查体验
2026-07-13 00:30:10 +08:00
SivanCola c0980b86fb fix(desktop): keep readiness pause visible and non-alarming
The sidebar now shows a readiness-paused turn as paused instead of
error, matching the transcript's recoverable presentation; a stale
paused badge yields to thinking once the next turn runs. The delivery
status card partitions outside the turn's process fold like warnings,
so its continue action stays reachable instead of collapsing with the
process items.
2026-07-12 16:15:36 +00:00
SivanCola c934d9d1fc fix(desktop): present readiness as recoverable status 2026-07-12 23:55:43 +08:00
SivanCola 57f95f0eb9 feat(desktop): localize notice copy by stable event codes
Known agent notices (final readiness, empty final answer, executor
handoff, tool budget, loop guard) now carry a wire-stable Code on the
event and history contracts. The desktop localizes notice main copy by
code first, so backend wording edits no longer silently break the
exact-text matching in localizedBackendNoticeText, which stays as the
fallback for codeless events and replayed histories.
2026-07-12 15:31:53 +00:00
SivanCola cd6ced82df fix(agent): lead opaque-coverage hint with the missing-paths reason
State why coverage is unknown before the recovery steps so the guidance
reads cause-first instead of attaching the reason to the submitted files.
2026-07-12 15:05:40 +00:00
SivanCola d253c0c229 fix(agent): explain opaque review coverage recovery 2026-07-12 22:54:42 +08:00
SivanCola 7951413df3 Merge pull request #6372 from SivanCola/fix/todo-panel-default-collapsed
fix(desktop): collapse todo panel by default
2026-07-12 22:53:21 +08:00
SivanCola 8232b8558b Merge pull request #6371 from SivanCola/feature/workspace-open-by-default
Open workspace panel on launch / 启动时默认展开工作区
2026-07-12 22:51:31 +08:00
SivanCola a1687ba229 test(desktop): merge duplicate default-collapsed assertions
shouldOpenTodoPanelByDefault no longer takes todos, so the incomplete and
completed cases exercised the identical call. Keep one assertion naming the
combined contract.
2026-07-12 14:45:45 +00:00
SivanCola a7dfe4834d Merge pull request #6361 from SivanCola/fix/rich-composer-duplicate-input
Fix duplicate rich composer input / 修复富文本输入重复显示
2026-07-12 22:44:07 +08:00
SivanCola 99d977c076 fix(desktop): collapse todo panel by default 2026-07-12 22:35:35 +08:00
SivanCola ca0762ba89 Open workspace panel by default 2026-07-12 22:34:34 +08:00
SivanCola e71e2be84f fix(desktop): preserve invocation offsets around pasted blocks 2026-07-12 22:20:07 +08:00
SivanCola 663abb0c63 fix(desktop): resync reverted rich composer drafts 2026-07-12 21:46:30 +08:00
SivanCola 0b69cf3fe8 Merge pull request #6367 from SivanCola/fix/diagnostics-null-array-crash
Fix diagnostics null-array crash / 修复诊断页空数组崩溃
2026-07-12 21:43:39 +08:00
SivanCola 501aec7794 Fix desktop null array contracts 2026-07-12 21:28:35 +08:00
J3y0r e8621cc3d6 fix(desktop): repaint creation transcript after turn-actions removal 2026-07-12 19:51:22 +08:00
SivanCola be89166f9a fix(desktop): prevent duplicate rich composer input 2026-07-12 19:06:14 +08:00
SivanCola a70c91a4fe Merge pull request #6351 from SivanCola/fix/transcript-fold-content-visibility
fix(desktop): keep assistant content and mid-turn steers outside the work fold / 正文与中途引导移出工作折叠
2026-07-12 18:32:40 +08:00
SivanCola 3d340fbca4 Merge pull request #6352 from SivanCola/fix/steer-queue-drop
fix(agent): flush unconsumed steers at turn exit instead of dropping them / 回合退出时保留未消费的引导消息
2026-07-12 18:32:27 +08:00
SivanCola d355eced95 Merge pull request #6310 from SivanCola/feature/subagent-profiles
Add configurable subagent profiles and isolated invocation / 新增可配置子智能体与隔离调用
2026-07-12 18:02:13 +08:00
SivanCola ec53a9e376 test(desktop): adapt stale-workspace fixtures to verbatim steer turns
The idle steer fallback now submits steer text verbatim as a provider turn
instead of command-interpreting it, so fixtures that steered
"/unknown-command" against an unreachable provider left the controller
running for the rest of the test. The fixture provider is now a local SSE
stub that completes turns instantly, and the quick-click serialization
test tolerates the busy errors racing actions can legitimately observe
while the steer's turn is in flight — it asserts rebuild serialization,
not that every concurrent action wins the turn.
2026-07-12 09:51:25 +00:00
SivanCola f53b92cadb fix(desktop): follow-up hardening for subagent profiles and composer entities
- Snapshot the active tab's WorkspaceRoot under a.mu before a try run
  resolves config; the field is rewritten under the lock by spelling
  normalization and session-binding redirects.
- AlwaysHiddenSubagentTools now also reports the background job tools
  (wait, bash_output, kill_shell) that every subagent registry strips
  unconditionally, so the profile tool picker no longer offers dead
  choices.
- Try runs are single-flight and cancellable: they derive from the app
  context, register a cancel handle, and a new CancelTrySubagentProfile
  binding backs a Cancel button on the settings form.
- SkillView.Body ships only for runAs=subagent skills — inline skills
  fold references/ into Body at load time and bloated every
  Capabilities/Settings fetch across the JSON bridge.
- Cancelling a run folds queued structured guidance back into the draft
  as its slash form instead of dropping the invocation.
- Goal mode blocks entity invocations until a plain-text goal exists;
  the first goal message used to swallow them as goal prose.
- Edit-replay keeps a hidden submit prefix (referenced-session context)
  on structured messages, matching the plain-text branch.
- Hydrated structured messages restore their invocation badges: reload
  resolves the serialized slash-form display, and a conservative
  leading-known-token fallback rebuilds the entity segments while prose
  slashes, unknown names, and mid-text tokens stay plain text.
- The profile name-collision error no longer claims every collision is
  a built-in name.
2026-07-12 09:28:30 +00:00
SivanColaandJesonChou 72562f6d79 fix(desktop): keep the turn timeline in segmented folds and sync the fold preference
Review follow-ups on the work-fold rework:

- partitionTurnItems returns ordered segments: process that ran after an
  answer or steer opens a new fold instead of being pulled ahead of it, so
  the transcript keeps the turn's real timeline. Answers and steers are the
  only boundaries — turns without intermediate content keep exactly one
  fold, and warn notices stay visible without splitting the fold. The
  closing fold carries the turn's work-duration label; earlier folds carry
  a counts-only label.
- Switching the work-process-fold preference now applies to folds already
  on screen through the preference event bus, clearing per-fold manual
  overrides, instead of only affecting newly mounted folds.

Co-authored-by: JesonChou <[email protected]>
2026-07-12 09:09:01 +00:00
SivanColaandJesonChou 51f9fd921d fix(agent): language-proof steer replay and park the steer fallback
Review follow-ups on the steer-loss fix:

- SteerText now skips the transient language blocks withTurnPreferences
  prepends (present for Chinese text even in auto mode) and cuts a trailing
  delivery-runtime marker, so persisted steers replay as steers instead of
  degrading into plain user messages — including sessions written before
  this fix.
- The steer fallback no longer routes through runGuarded's deliberately
  silent running drop: runGuardedOrPark parks the turn while another is
  running or finishing and the FIFO drain delivers it when the window
  closes. This closes the loss window between a turn's steer-queue flush
  and running=false, and also converts steers with no bound executor into
  a regular turn (#6350).

Co-authored-by: JesonChou <[email protected]>
2026-07-12 09:08:49 +00:00
SivanCola 3fecbd4410 fix(desktop): harden subagent composer flows and profile CRUD admission
- Refuse subagent profile create/update/delete up front while the
  controller has active runtime work: the post-save RefreshSkills
  rebuild would be rejected anyway, and a file already written by then
  strands the UI (save reports failure, the list never refreshes, and a
  create retry hits "already exists"). Mirrors the applyConfigChange
  precheck order.
- Silence the rich composer input while an IME is composing: no model
  resync and no selection restore (removeAllRanges cancels or commits a
  composition mid-word) until compositionend performs one authoritative
  sync. Composition tracking uses native listeners so the guard does not
  depend on React's composition plugin fallback.
- Hand focus and the caret to the plain textarea when removing the last
  invocation entity unmounts the rich input, so the next keystrokes no
  longer land on <body>.
- Queue an entity-only submit as guidance (rendered with its slash
  form) while a turn is running instead of silently dropping it while
  clearing the draft.
- Keep literal "#..." text when dismissing the typed recent-session
  picker: Escape and close only dismiss (the slash/@ menu contract),
  selecting a session remains the only token-consuming path, the panel
  follows the live token, and token-driven flows no longer steal focus
  into the panel search box.
2026-07-12 08:51:05 +00:00
SivanCola 9f954bbfe5 fix(desktop): keep assistant content and mid-turn steers outside the work fold
Per-turn folding hid every assistant message except the last one behind
the Worked-Ns collapse, conflating the model's answer channel with its
reasoning (#4092). partitionTurnItems now splits by channel: reasoning,
tools, phases, info notices, and compaction fold; every assistant message
with answer text renders outside in order, and mid-turn steers render as
user-side bubbles instead of disappearing into the fold. The fold label
counts what it hides, a fold with nothing outside stays expanded, each
reasoning segment gets its own toggle (#6340), and a display setting can
keep folds expanded after completion (#4233).
2026-07-12 06:23:16 +00:00
SivanCola 19903f6908 fix(agent): flush unconsumed steers at turn exit instead of dropping them
Run's deferred clearSteerQueue silently dropped steers still queued when
a turn was cancelled, errored, or paused; persistence only happened at
consume time, so the guidance vanished from both the model's context and
history (#6238). The exit path now drains the queue into the session and
emits the Steer events, Agent.Steer rejects text when no turn is active
so the controller converts it into a regular turn, and the composer only
removes queued guidance on an exact text match instead of eating the
first queued item on any consumed-steer notice.
2026-07-12 06:23:16 +00:00
SivanCola 21b95c47a7 fix(desktop): focus composer from blank space 2026-07-12 13:54:33 +08:00
SivanCola 4f4aabd83a fix typed recent session trigger 2026-07-12 13:31:12 +08:00
SivanCola e6a614d59a refine desktop content menu affordances 2026-07-12 13:16:36 +08:00
SivanCola 19d532d452 Merge pull request #6347 from SivanCola/fix/project-default-model-fallback
fix(config): fall back to user default_model when a project override is unresolvable / 项目 default_model 失效时回退到用户全局默认模型
2026-07-12 13:12:17 +08:00
SivanCola 7b2aa30940 fix desktop content menu visual regression 2026-07-12 05:01:30 +00:00
SivanCola 977275cdd7 test(boot): cover project model fallback notice 2026-07-12 12:56:40 +08:00
SivanCola 0d0ac37e2a Merge pull request #6345 from SivanCola/fix/surface-provider-error-body
fix(error): surface the provider's real reason on 402/429/5xx and 401/403 / 402/429/5xx 与 401/403 错误透出上游真实原因
2026-07-12 12:39:01 +08:00
SivanCola 694c41127c fix lint for subagent orchestration 2026-07-12 04:38:53 +00:00
SivanCola 4a40e44b90 fix subagent invocation and profile safety 2026-07-12 12:22:14 +08:00
SivanCola 8470d2180e fix(config): fall back to user default_model when a project override is unresolvable
Pre-v1.11 persistence paths (e.g. the "always allow" permission writer)
full-rendered ./reasonix.toml and pinned the built-in default_model
("deepseek-flash") into project folders. Once the user's [[providers]]
replaced the built-in presets, that stale name resolved to nothing and
every launch from that folder hard-failed with:

  unknown model "deepseek-flash" (configured: deepseek-pro); note:
  defining [[providers]] replaces the built-in presets, ...

The writer was fixed in 2419ca27, but existing poisoned project files
still cannot boot on current builds.

LoadForRoot now falls back to the user-global default_model when a
project override resolves to no configured provider, and boot surfaces
a warn notice naming the ignored value. The fallback only fires when
the user config explicitly defines default_model, so a project-only
config with a broken ref keeps the actionable boot error. In-memory
only: the project file is never rewritten, and a project override that
does resolve still wins.

TestBuildUnknownModelErrorIsActionable now isolates REASONIX_HOME to
make its project-file-is-the-only-config precondition explicit instead
of depending on the machine's real user config.

Fixes #4218
2026-07-12 04:17:17 +00:00
SivanCola 1830c4b520 fix(error): close the digit-free key gap in auth reason redaction
The token fallback required a digit, but keys are opaque strings — a
sloppy relay reflecting "sk-proj-abcdefghijklmnop" walked straight
through the scrub the threat model promised to stop. Layer the redaction
instead: secrets.Redact first (known key prefixes, Bearer/JWT, KEY=value
forms — digit or not), a credential-context rule for the prose forms
secrets.Redact doesn't cover ("api key: <value>" masks the value
unconditionally), full collapse of masked fragments (including the
head/tail remnants secrets' own mask leaves), then the token fallback
widened from has-a-digit to has-a-digit-or-mixed-case. Single-case
digit-free identifiers (invalid_authentication_token,
INVALID_AUTHENTICATION_TOKEN) stay readable; the residual blind spot —
a long single-case digit-free token with no context word — is named in
the comment.
2026-07-12 04:12:00 +00:00
SivanCola 9ead6b5a95 Merge remote-tracking branch 'origin/main-v2' into fix/pr6310-followup
# Conflicts:
#	desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx
2026-07-12 12:03:22 +08:00
SivanCola e6057b1434 fix(error): redact key echoes from the displayed auth reason
Auth failure bodies are where servers echo credentials: DeepSeek-style
messages contain the masked tail ("Your api key: ****ae54 is invalid"),
and a sloppy relay can reflect the full key it received. The displayed
turn error can travel further than the user's terminal (bot forwarding,
shared screenshots), so scrub both forms from the reason explainError
appends for 401/403: provider-masked fragments and key-shaped tokens
(16+ [A-Za-z0-9_-] containing a digit) become "****", while digit-free
identifiers like invalid_authentication_token stay readable. Other
statuses keep their reason untouched — their bodies don't carry
credentials, and 400 schema errors legitimately contain long identifiers.
2026-07-12 03:53:16 +00:00
SivanCola e430190ddd Merge pull request #6341 from SivanCola/feature/runtime-profile-work-mode
TUI work-mode hot switching and desktop polish / TUI 工作模式热切换与桌面打磨
2026-07-12 11:47:32 +08:00
SivanCola 8dab555262 fix(provider): keep the auth body out of AuthError.Error()
TestStreamAuthError pins a deliberate contract the previous commit broke:
the ambient auth error string must not include response-body content,
because servers echo masked key fragments ("****ae54") in 401 bodies and
Error() flows into logs, status lines, and traces where key material must
never propagate.

Keep AuthError.Body and the explainError surfacing (the display layer
extracts the reason deliberately); drop only the "server said: ..."
suffix from Error(), and repin the contract in both directions:
TestAuthErrorBodyStaysOutOfError asserts the body rides the struct but
stays out of the string.
2026-07-12 03:39:58 +00:00
SivanCola 57bf09a5ba Merge remote-tracking branch 'origin/main-v2' into fix/pr6310-followup
# Conflicts:
#	desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx
#	desktop/frontend/src/components/Composer.tsx
#	internal/skill/skill.go
2026-07-12 11:35:06 +08:00
SivanCola d7f81714ec fix(error): carry the auth response body so 401/403 reasons surface too
Sibling of the previous commit: SendWithRetry read the 401/403 response
body but dropped it when building AuthError, so a relay's explanation of
*why* auth failed ("token expired", key not entitled to the model) never
reached the user — the actionable line named the key env to fix, but a
valid-yet-rejected key looked identical to a dead one.

Add AuthError.Body, populate it from the already-read snippet, append it
to AuthError.Error() as "server said: …", and surface it through
explainError's auth branch the same way APIError bodies surface. Output
is byte-identical when the server returned no body.
2026-07-12 03:25:55 +00:00
SivanCola 22194e3ebf Merge remote-tracking branch 'origin/main-v2' into feature/runtime-profile-work-mode
# Conflicts:
#	desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx
#	desktop/frontend/src/components/Composer.tsx
#	desktop/frontend/src/locales/en.ts
#	desktop/frontend/src/locales/zh-TW.ts
#	desktop/frontend/src/locales/zh.ts
#	desktop/frontend/src/styles.css
2026-07-12 11:16:29 +08:00
SivanCola 29c260edb6 Handle trailing newlines in composer triggers 2026-07-12 11:15:52 +08:00
SivanCola 3812eef49d fix(error): surface the provider's reason on 402/429/5xx too
explainError kept the upstream response body only for the request-shaped
4xx (400/422) and collapsed every other mapped status to its bare
localized category line. Relay gateways (one-api/new-api style) wrap the
real cause -- dead upstream channel, unsupported tools, exhausted quota --
in a 402/429/500/503 JSON body, so exactly the statuses a relay returns
were the ones a user could not diagnose: every request through a broken
relay showed only "Server error (HTTP 500): the provider hit an internal
fault" while other clients displayed the relay's reason.

Drop the 400/422 guard in the (renamed) apiErrorReason so every status
that maps to a localized line appends the body reason, still clamped to
800 runes; statuses without a mapping already pass through
APIError.Error() with the body attached. This deliberately flips the
"429 body must not leak" pin from f920da10 -- that commit scoped body
surfacing to the request-shaped 4xx as a first step, not for privacy
reasons, and the 429/5xx body is as diagnostic as the 400/422 one.
2026-07-12 03:15:44 +00:00
SivanCola 11d977236f Merge pull request #6322 from SivanCola/feature/delivery-first-runtime-profiles
Runtime profiles with delivery capability routing / 运行档、交付能力路由与质量门禁
2026-07-12 10:56:55 +08:00
SivanCola 3a2e20aa3a test: cover composer content menu run transitions 2026-07-12 10:46:58 +08:00
SivanCola 5c56460895 fix: satisfy capability lint checks 2026-07-12 03:48:36 +08:00
SivanCola f0c00d7de6 Handle trailing newlines in slash commands 2026-07-12 03:36:44 +08:00
SivanCola 73d4817a97 style: use tagged switch in limitRouteCandidates (staticcheck QF1003) 2026-07-11 19:35:54 +00:00
SivanCola 8bab8fc347 test: stabilize composer tooltip focus 2026-07-12 03:34:49 +08:00
SivanCola 1587e94580 Merge remote-tracking branch 'origin/main-v2' into feature/runtime-profile-work-mode 2026-07-12 03:31:28 +08:00
SivanCola 38f3774ada feat: add TUI work-mode switching 2026-07-12 03:31:09 +08:00
SivanCola f3a7919ef8 Merge remote-tracking branch 'origin/main-v2' into feature/delivery-first-runtime-profiles 2026-07-11 19:29:54 +00:00
SivanCola 4ec9c88363 fix: unbreak CI vet and composer content-menu interactions
- print Audit snapshot fields instead of the struct in the two new test
  fatals: passing the snapshot (it embeds sync.Mutex) to t.Fatalf copies
  a lock and fails go vet, which was failing lint and all three platform
  test jobs
- composer-goal-toggle: drain the insert request's queued rAF refocus
  before focusing the execution-method trigger — the late refocus blurred
  the trigger and cancelled the tooltip's show timer, failing the tooltip
  assertion
- content menu: the '/' item now disables (with an explanatory hint in
  all three locales) while the composer has text — slash commands only
  parse as the whole message, so inserting ' /' mid-text was a silent
  no-op
- close the content menu, direct session picker, and past-chats state
  when a run starts: the popover gates open on !running, so stale open
  state made the menu pop back unprompted when the turn finished
2026-07-11 19:29:46 +00:00
SivanCola 650b4bd9ca Preserve custom subagent colors in chat 2026-07-12 02:47:57 +08:00
SivanCola 3bf6b6a7a9 Add composer content menu 2026-07-12 02:42:03 +08:00
SivanCola 05b7ad1a94 Support inline skill and subagent entities 2026-07-12 02:21:15 +08:00
SivanCola 6a4231dd40 strengthen delivery capability routing 2026-07-12 02:04:16 +08:00
SivanCola c3b6390e99 default new desktop sessions to auto approval 2026-07-12 01:32:38 +08:00
SivanCola 19d580ccda feat(desktop): show structured skill invocations 2026-07-12 01:24:42 +08:00
SivanCola c22bc17af1 simplify runtime profile icons 2026-07-12 01:20:14 +08:00
SivanCola f78d49ab6b unify composer control tooltips 2026-07-12 01:15:42 +08:00
SivanCola 267783abae rename direct execution method to standard 2026-07-12 01:08:42 +08:00
SivanCola c68759e769 simplify execution method trigger 2026-07-12 01:04:23 +08:00
SivanCola d7969c54bd simplify runtime profile trigger 2026-07-12 01:01:21 +08:00
SivanCola a898f724ec simplify runtime profile summaries 2026-07-12 00:57:31 +08:00
SivanCola d0e8ad60a7 refine execution method copy and approval colors 2026-07-12 00:53:08 +08:00
SivanCola fc30cb66d3 fix(desktop): refine slash command groups 2026-07-12 00:48:50 +08:00
SivanCola f967a53061 feat(desktop): group slash command menu 2026-07-12 00:42:34 +08:00
SivanCola 8795de7219 refine composer control hierarchy 2026-07-12 00:40:19 +08:00
SivanCola a9b05c732d fix(desktop): refresh subagent slash commands 2026-07-11 23:50:29 +08:00
SivanCola a93fb38d08 feat: redesign task method selector 2026-07-11 23:35:14 +08:00
SivanCola 78e9e2656a Merge pull request #6334 from SivanCola/fix/missing-reasoning-warn-once
fix(agent): warn once per session on missing tool-call reasoning / 缺失思维链告警改为每会话一次
2026-07-11 23:26:01 +08:00
SivanCola a0f2661bc6 feat(desktop): launch subagents from settings 2026-07-11 23:13:45 +08:00
SivanCola 07cb5644a7 refine work mode icon semantics 2026-07-11 23:11:24 +08:00
SivanCola 8adb814db7 fix(agent): dedupe missing tool-call reasoning warning to once per session
DeepSeek thinking-mode sessions can hit tool_calls turns that carry no
reasoning_content on every round (endpoint-conditional; reported on the
official API as well as behind gateways). The per-round warn notice then
floods the transcript with identical cards while adding no signal beyond
the first occurrence (#6259, #6333).

Warn on the first missing-reasoning round only, drop the refuted "is a
gateway dropping the reasoning field?" guess from the text, and move the
impact explanation into the notice detail. SetSession re-arms the dedupe
so a swapped-in conversation still gets its one warning.

Refs #6259 #6333 #6173
2026-07-11 15:10:13 +00:00
SivanCola 73fb8a6138 feat desktop work mode selector 2026-07-11 23:02:44 +08:00
SivanCola 957f475daf fix(desktop): unify subagent override pickers 2026-07-11 22:44:45 +08:00
SivanCola 3cfc30d421 fix delivery capability enforcement gaps 2026-07-11 22:26:05 +08:00
SivanCola 3a87d8d258 fix(desktop): align built-in subagent controls 2026-07-11 22:23:55 +08:00
SivanCola 3034b41da7 fix(desktop): prioritize custom subagents 2026-07-11 22:00:31 +08:00
SivanCola 78e7b710b6 Merge remote-tracking branch 'origin/main-v2' into feature/delivery-first-runtime-profiles 2026-07-11 13:51:33 +00:00
SivanCola 7d12d4c1b4 fix: gated first MCP discovery, shared-host snapshots, output-backed review evidence
Address the third review round:

- first discovery (P1): action=call on an mcp-server id resolves to a
  deferred connect target — permission-named with the server's bare
  mcp__<server>__ prefix so server-scoped rules match, ReadOnly=false so
  plan mode blocks it and Ask gates prompt before any process spawns —
  whose Execute connects and returns the live tool directory; connected
  servers list side-effect-free, and the Delivery route block now
  instructs exactly that flow for server candidates instead of an
  uncallable capability id
- shared host (P1): the resolve and inspect already-connected branches
  go through the snapshot-refreshing serverTools helper, so a server
  connected by a sibling tab still yields routable mcp-tool entries in
  this tab's catalog
- review evidence (P1): receipts record host-observed output length;
  bash review evidence now requires non-empty output plus an AST-parsed
  content command (cat/head/tail/diff/cmp, git diff/show) whose static
  argv names the claimed path exactly — pipelines, redirects, summary
  and quiet flags (--stat, --name-only, -q), dynamic expansions, and
  substring path hits (path.bak) are all rejected
- stale cache (P2): cached tools of a fingerprint-mismatched server
  inherit StatusStale so routing prompts expose the outdated schema

End-to-end coverage runs against a real stdio MCP helper: no-cache
connect-list-call, cross-tab shared-host snapshots via resolve and
inspect, plan-mode blocking of the connect target, and seven
content-evidence bypass shapes.

Provider-visible schemas and tool ordering remain unchanged; on-demand
tools stay off the main registry (cache-guard 8/8, tail 92-95%).
2026-07-11 13:51:23 +00:00
SivanCola 6de67dfabe fix(desktop): initialize custom subagent tools 2026-07-11 21:38:33 +08:00
SivanCola 47875cc020 Merge pull request #6327 from SivanCola/fix/mcp-tuple-schema-compat
Fix MiMo legacy tuple tool schemas / 修复 MiMo 旧版元组工具 Schema
2026-07-11 21:36:58 +08:00
SivanCola 18d4ed9ee6 fix(provider): keep dialect updates inside their own schema resource
Round-3 review follow-up: a converted child that declares its own
$schema is an independent schema resource, but its changed flag still
propagated to every ancestor — so a 2019-09 parent embedding a
converting 2020-12 $defs resource had its untouched declaration
"upgraded", silently changing the semantics of unrelated parent
keywords.

normalizeDraft202012Schema now returns two signals: resourceChanged
(conversions belonging to the caller's resource, stopping at $schema
boundaries) and documentChanged (anything anywhere, driving
reserialization). A resource settles its own declaration update and
returns resourceChanged=false to its parent.

Regressions cover the reviewer's repro (2019-09 parent + converting
2020-12 nested resource keeps the parent dialect) and the contrast case
(a converting subschema without its own $schema still updates the
parent's legacy declaration).
2026-07-11 13:26:02 +00:00
SivanCola 9c17e378ce Merge main-v2 into feature/subagent-profiles 2026-07-11 21:14:17 +08:00
SivanCola 4d4d469c33 Merge pull request #6324 from SivanCola/fix/turn-admission-parked-submit
Park turns landing in the finishing window instead of dropping them / 修复 finishing 窗口内提交被静默丢弃
2026-07-11 21:07:44 +08:00
SivanCola af7ef8a87b fix(provider): drop the global schema memo, leave unknown dialects whole
Round-2 review follow-ups on the tuple normalizer:

- The process-global sync.Map memoized externally supplied MCP schema
  bytes as both key and value with no eviction, so a long-lived desktop
  process accumulated every distinct schema across projects, sessions,
  and server reconnects. The memo is gone; the lexical "items" gate
  keeps the common no-op case at zero parses, unchanged schemas still
  return their input bytes, and the rare legacy-tuple schema is cheap
  to re-convert per request.
- Tuple keywords were rewritten before the resource's own $schema was
  classified, so an unknown/custom dialect kept its declaration while
  items:[...] became prefixItems — a partial conversion under semantics
  this code cannot know. Each schema resource's dialect is now
  classified first: known legacy drafts convert (and update their
  declaration), explicit 2020-12 repairs malformed tuple syntax, and an
  unknown dialect leaves the entire resource subtree byte-identical.

Regressions cover the custom dialect at root (byte-identical) and
nested beside a converting sibling, the explicit 2020-12 repair, and
determinism without the cache.
2026-07-11 13:05:56 +00:00
SivanCola c9e35c260d Merge remote-tracking branch 'origin/main-v2' into feature/delivery-first-runtime-profiles 2026-07-11 12:52:55 +00:00
SivanCola f6496b3ecb fix: canonical MCP tool names, delivery-aware routing, strict review evidence
Address the second review round on the Delivery capability runtime:

- canonical names (P0): export plugin.ModelToolName (collision-hash
  aware) and use it for every proxy-resolved target; the proxy-local
  normalizeToolToken is deleted, so permission/hook deny rules written
  for the executed name (e.g. raw tools with @, /, spaces) always match
- delivery routing (P1): RouteDecision carries a Delivery flag; the
  transient route block keeps concrete mcp-tool ids and instructs
  use_capability(action="call", ...) instead of connect_tool_source,
  which is not registered in Delivery
- catalog persistence (P1): the proxy snapshots host-observed live
  tools of on-demand connected servers (never touching the registry);
  boot and controller pass the snapshot as CatalogOptions.ProxyTools so
  mcp-tool entries stay routable after the server turns ready, without
  duplicating registry-backed servers
- review evidence (P1): HasReadEvidenceForPath replaces the observed-
  path check — only read receipts (exact or fuller-path slash-suffix)
  and content-revealing commands (diff/cmp/cat/head/tail, git diff/
  show) count; git status/echo mentions, write receipts, and reverse
  basename suffix matching are rejected
- windows CI (P1): review_report error text lists reviewed paths in
  slash-canonical form so messages and tests match across OSes
- metrics (P2): action=call proxy audit moves to the agent loop's
  single post-execution point (no double count, successes now counted);
  capabilityGateFailure tracks per-turn misses and records
  RequireRecovered/PreferRecovered via the new RecordGateRecovery when
  the gate later passes clean

Provider-visible schemas and tool ordering remain unchanged; on-demand
tools still stay off the main registry (cache-guard 8/8 pass, tail hit
rate 92-95%).
2026-07-11 12:52:38 +00:00
SivanCola 462074f406 fix(desktop): clarify inherited subagent tools 2026-07-11 20:50:53 +08:00
SivanCola 3af1f5b882 Merge pull request #6328 from SivanCola/feature/plugin-skill-qualified-names
feat(plugin): qualify plugin skill slash names / 插件技能使用限定斜杠名称
2026-07-11 20:49:59 +08:00
SivanCola e681f596b4 test: guard qualified plugin skill completion 2026-07-11 20:41:15 +08:00
SivanCola ae1c9c270b fix(provider): sync schema dialect declarations and memoize normalization
Review follow-ups on the MiMo tuple-schema normalizer:

- A schema declaring an older dialect (draft-03..07, 2019-09) kept that
  $schema after its tuple keywords were rewritten to 2020-12 form,
  making the output self-contradictory — prefixItems under a draft-07
  declaration can legally be read with the wrong tuple semantics. The
  recursive pass now reports whether it changed anything and updates an
  old-draft $schema on every schema resource whose subtree converted
  (root and nested $defs resources alike); unknown or custom dialect
  URIs stay untouched.
- The normalizer unconditionally re-parsed and re-serialized every tool
  schema on every request from both MiMo builders, defeating the
  registry's one-time canonicalization for the ~200-tool surfaces that
  motivated the PR. Schemas without an "items" keyword now return their
  input bytes without a parse, unchanged schemas return their exact
  input bytes, and conversions are memoized process-wide (pure function,
  bounded by the distinct schemas seen).

The modern-array-items test pinned the old always-reserialize behavior
(key-sorted output); it now asserts the stronger contract that no-op
inputs come back byte-identical.
2026-07-11 12:38:53 +00:00
SivanCola ece6220cb7 fix(desktop): focus subagent profile editor 2026-07-11 20:30:29 +08:00
SivanCola 948901e3ac Merge remote-tracking branch 'origin/main-v2' into feature/delivery-first-runtime-profiles
# Conflicts:
#	docs/GUIDE.md
#	docs/GUIDE.zh-CN.md
2026-07-11 10:51:07 +00:00
SivanCola 1f7a4efd87 fix: harden use_capability gating, lifecycle, and review evidence
Address the six review findings on the Delivery capability runtime:

- plan mode (P0): re-run the plan-mode policy after proxy resolution
  against the real target's name/read-only/trust/safety, so
  use_capability(action=call) can no longer reach a write-capable MCP
  tool in plan mode; trusted read-only targets still pass
- permission-before-start (P0): ResolveCall is now side-effect free —
  unconnected servers resolve to a deferred target that connects in
  Execute, after the permission gate and PreToolUse hooks; inspect lists
  live tools or cached schemas and never starts a process
- lifecycle (P0): on-demand connects use AddWithLifecycle(sessionCtx,
  handshakeCtx) so the stdio child survives the 5s handshake budget and
  exits with the session instead of dying before the tool call runs
- capability gate (P1): finalReadinessCheck now checks the require/
  prefer gate before the no-writer early return, so read-only answers
  cannot skip a user-required capability
- review_report (P1): reviewed_paths must be backed by host-observed
  read/diff receipts in the subagent's own ledger; Delivery review/
  security subagents fail their parent call when they finish without a
  kind-matching report instead of retrying silently
- unified specs (P1): the proxy consumes boot-converted []plugin.Spec
  (env expansion, workspace overrides, timeouts, trusted read-only
  tools) instead of hand-copying config fields
- cached tools (P1): Boot and Controller catalog builds now pass
  CachedTools/CacheHashOK loaded from the shared spec fingerprints, so
  auto_start=false servers surface concrete mcp-tool candidates and
  stale caches are marked
- minor: SemanticRouter prices its usage events and records router
  token/cost/latency into the capability audit; the doctor illegal
  profiles warning is reachable again via Skill.InvalidProfiles

The provider-visible tool schema and ordering are unchanged; the proxy
keeps its stable schema and on-demand tools stay off the main registry
(cache-guard tail hit rate 92-95%, unchanged).
2026-07-11 10:49:54 +00:00
SivanCola 865da178cd fix: remove obsolete skill lookup helper 2026-07-11 18:49:45 +08:00
SivanCola ac2aa357c8 feat(plugin): qualify plugin skill slash names 2026-07-11 18:39:50 +08:00
SivanCola 7d98ddf285 Merge main-v2 into feature/subagent-profiles 2026-07-11 18:26:55 +08:00
SivanCola 6b2e54f00d fix(control): seal admission on close; gate RunTurn on the finishing window
Two review findings on the parked-turn admission change:

1. close() only cleared the parked queue that existed at that instant.
   A submit arriving after that critical section — while a still-running
   turn's TurnDone delivery was in flight — saw finishing=true, parked
   again, and the drain started it against freed resources once the
   window closed. Admission is now sealed by a closed flag maintained
   under the same c.mu: close() sets it alongside clearing the queue,
   runGuarded rejects with a new turnDroppedClosed result (deliberately
   silent — the controller's transports are being torn down and the
   input's home is the replacement controller the host swaps in), and
   the finishGuardedTurn drain refuses to start anything once closed.

2. The synchronous RunTurn gated on running || rotating but not
   finishing, so an ACP-style caller could start a turn inside the
   previous turn's TurnDone delivery window — recreating exactly the
   completion/transport crosstalk the window exists to prevent (and
   that Running() already reports as busy). finishing and closed are
   now part of the gate; synchronous callers get ErrTurnRunning rather
   than parking, since they hold a request/response boundary open and
   already handle busy.

Deterministic regressions (window held open by a sink blocking inside
TurnDone delivery): Close-then-submit-then-release must reject at
admission and start nothing when the window closes; RunTurn during the
window and after Close must return ErrTurnRunning promptly. Full
internal/control passes under -race; full root and desktop suites pass
— confirming nothing depended on the previous submit-after-close
behavior (which silently STARTED a turn on a closed controller).
2026-07-11 10:22:20 +00:00
SivanCola 487679e4e3 fix(desktop): refine subagent profile settings 2026-07-11 18:17:45 +08:00
SivanCola 7e9083e7c8 Merge origin/main-v2 into fix/turn-admission-parked-submit 2026-07-11 10:15:39 +00:00
SivanCola db44434dee fix(provider): scope legacy tuple schemas to MiMo 2026-07-11 18:13:59 +08:00
SivanCola 72597aae33 Merge pull request #6326 from SivanCola/feature/plugin-command-qualified-names
feat(plugin): qualify plugin slash commands / 插件斜杠命令使用限定名称
2026-07-11 18:11:46 +08:00
SivanCola d6d59f0ab6 Merge pull request #6323 from SivanCola/feature/reasonix-self-diagnostics
feat: built-in reasonix-guide and unified capability diagnostics / 内置 reasonix-guide 与统一能力诊断
2026-07-11 18:04:42 +08:00
SivanCola fdefa166f7 Merge main-v2 and align capability diagnostics
Preserve the diagnostics and decision-surface test suites while integrating plugin command capabilities into the report contract.
2026-07-11 17:53:55 +08:00
SivanCola 4e61d66c6f feat(plugin): qualify plugin slash commands 2026-07-11 17:37:53 +08:00
SivanCola 861416115d docs: document subagent profile workflows 2026-07-11 17:35:23 +08:00
SivanCola 656e983d88 feat(cli): manage and run subagent profiles
Add explicit list, create, edit, delete, try, and run commands backed by the same profile files and model overrides as Desktop. Keep try read-only and preserve ordinary reasonix run semantics.
2026-07-11 17:14:26 +08:00
SivanCola 1f5740a212 Merge pull request #6319 from SivanCola/feature/unified-decision-surface
feat(desktop): unify footer decision surface / 统一 footer 决策面
2026-07-11 16:56:55 +08:00
Sivan e7e5bc2c0d feat: add runtime profiles and delivery capability routing
Add Economy/Balanced/Delivery runtime profiles with host-enforced delivery
gates, hybrid capability routing, a Delivery-only use_capability MCP proxy,
risk-adaptive structured review, skill requires/profiles metadata, and
capability metrics for e2ebench.
2026-07-11 16:56:03 +08:00
SivanCola 39e711830d fix(capdiag): route transport error text through the shared secret redactor
The capdiag sanitizer kept its own short list of credential shapes
(Bearer / Authorization= / token= / api_key= / PATH=), so the raw HTTP
response bodies (up to 4KB) and stdio stderr that transport errors embed
could leak JSON credentials ("access_token":"..."), colon-separated
header values, and cookie values into shareable diagnostic reports.

- extend secrets.Redact: keyValuePattern now tolerates JSON-quoted keys,
  and Cookie/Set-Cookie header values are masked pair-by-pair; both
  changes stay idempotent, preserving session digest stability
- sanitizeErrTextWithPaths reuses secrets.Redact instead of maintaining
  a second, narrower recognizer; PATH= and short-Bearer handling stay
- regression tests: HTTP 401 JSON body + Cookie, JWT, GitHub and OpenAI
  tokens, colon-separated headers, Set-Cookie
- document the redaction pipeline in CAPABILITY_DIAGNOSTICS (en/zh)
2026-07-11 08:53:00 +00:00
SivanCola c9991daca1 Merge pull request #6311 from SivanCola/fix/claude-plugin-commands-mapping
feat(plugin): map Claude plugin commands to custom slash commands / 将 Claude 插件 commands 映射为自定义斜杠命令
2026-07-11 16:32:06 +08:00
SivanCola 227201294f fix: isolate slash-invoked subagent profiles
Run manual subagent slash commands in an isolated child loop while retaining only the task and distilled answer in the parent session. Keep custom-path profiles read-only in Settings and reject unsupported or mismatched mutation scopes.
2026-07-11 16:10:09 +08:00
Sivan 270c948724 fix: address capability diagnostics review findings
Make static collection truly read-only (LoadForRootReadOnly), harden error
redaction for paths/PATH/tokens, fix Windows pipe capture deadlock, surface
unknown Hook events, use HasTools for no-tools warnings, skip live MCP cache
persistence, and take an atomic desktop tab snapshot with last-request-wins UI.
2026-07-11 16:03:24 +08:00
Sivan 46a2485b19 fix(desktop): track approval/ask wait in per-tab controller state
User-wait for approval and ask now starts at the event on each tab's
controller state, so background suspension is excluded from model work time
instead of only counting once Composer mounts the draft. Composer consumes
turnWaitAccumMs/promptWaitStartedAt and only keeps a local pause for
controller-unknown surfaces (clear-context / legacy tests).
2026-07-11 15:48:36 +08:00
Sivan 4b28e9ac08 fix(desktop): ask keyboard confirm and scoped pause timing
Make single-select Ask confirm follow the keyboard cursor (initial Enter /
ArrowDown+Enter), keep multi-select cursor distinct from checked state, and
scope the composer wait clock by draftKey+turnStartAt so suspended tab
switches do not leak pause intervals.
2026-07-11 15:25:52 +08:00
SivanCola dea783d1e0 fix(plugin): derive the update backup path from the staging name
Round-6 review follow-up: plugin names may legally contain dots, so the
fixed "<target>.pre-replace" backup path could be a sibling plugin's
real install dir — updating "foo" pre-cleaned and then deleted the
installed "foo.pre-replace" plugin while its registration survived.

The backup now derives from the staging directory name: dot-prefixed
and randomized, it can never pass IsValidName, so no valid plugin can
occupy it; the uniqueness also removes the pre-cleanup RemoveAll that
did the damage. Regression installs "foo" and "foo.pre-replace" side by
side and verifies the sibling's files and registration survive a
replace=true update of "foo".
2026-07-11 07:25:12 +00:00
SivanCola 4a57bb867d fix(control): park turns landing in the finishing window instead of dropping them
runGuarded treated running, finishing, and rotating as one silent no-op,
but the three states carry different user intent:

- finishing: TurnDone is emitted INSIDE this window (finishGuardedTurn
  sets running=false/finishing=true, emits, then clears finishing), so
  from every caller's perspective the turn is already over — submitting
  again is legitimate. Silently dropping it loses real input: any caller
  that submits upon seeing turn_done (a frontend's queued auto-send, a
  bot, a fast Enter) races the window. Observed as a flaky lost turn in
  CI, reproduced on a clean main-v2 worktree under load, and already
  worked around in Composer.tsx (its auto-send is gated on
  submitDisabled instead of turn_done precisely because an item was
  "removed as if it had sent").
- rotating: the executor session is being swapped; which session the
  input was meant for is ambiguous, so guessing is worse than refusing.
- running: interactive frontends already own the steer/queue UX and
  internal opportunistic callers (goal-loop continuations, replays)
  rely on the quiet no-op.

runGuarded now returns an admissionResult and treats the states
accordingly: finishing parks the body in a FIFO; rotating refuses with
a user-visible warn Notice asking to resend; running stays a silent
no-op. finishGuardedTurn starts the oldest parked turn under the SAME
critical section that clears finishing — opening the gate first would
let an unrelated submit slip ahead and bounce the parked turn back to a
drop. Remaining parked turns drain one per turn end, preserving order.
Rotation cannot interleave with a drain: beginRotation refuses while
running or finishing, and the drain flips finishing directly into
running. close() discards parked turns so one cannot start against a
controller being torn down.

TestRunGuardedRejectsReplacementUntilTurnDoneReturns pinned the old
drop behavior; it is renamed to ...ParksReplacement... and now asserts
both halves of the revised contract: nothing starts inside the window
(the transport-crosstalk guarantee the window exists for), and the
parked turn starts exactly once when the window closes. New
admission_test.go covers FIFO order across multiple parked submits,
the rotating Notice, the unchanged silent running no-op, and
Close-discards-parked. All run clean under -race (10x for the new
tests; full package).

The finishing window itself is unchanged — TurnDone delivery still
completes before any successor turn starts. Longer-term, turn-ID-scoped
sink fan-out could remove the window (and this queue) entirely; left
out deliberately as a larger transport-contract refactor.
2026-07-11 07:25:03 +00:00
Sivan eb066afaa1 feat: built-in reasonix-guide and unified capability diagnostics
Add an on-demand builtin skill and a shared read-only diagnostics model for
Skills, Commands, Hooks, plugin packages, MCP, and instruction docs. CLI
defaults to static probes; --live uses an isolated Host. Desktop Settings
exposes the same report without starting MCP or mutating config.
2026-07-11 15:09:22 +08:00
SivanCola 7e2a41ee2b test(control): wait out the finishing window instead of racing TurnDone
Root-caused the recurring internal/control timeout flakes (seen twice in
this PR's CI race job at 2s and once in the coverage job after the
deadline was raised to 30s — the full 30s elapsing proved the awaited
event never arrives, not that scheduling was slow):

finishGuardedTurn deliberately emits TurnDone INSIDE the finishing
window (running=false, finishing=true, Emit, then the deferred
finishing=false), and runGuarded silently no-ops while finishing is set
— its documented "no-op if a turn is already in flight" admission rule.
So "received TurnDone" does not mean "may submit the next turn":

- TestSubmitUserTurnBypassesCommandDispatch submitted its second turn
  the instant TurnDone arrived; raced into the window, the submit was
  silently dropped and the second TurnDone never came (any deadline
  times out). Reproduced on a clean origin/main-v2 worktree under
  whole-repo test load — pre-existing, not a PR regression.
- TestCancelClearsPending{Approval,Ask}RuntimeStatus asserted an idle
  RuntimeStatus immediately after TurnDone; within the window
  Running() is still true by design.

Add a waitIdle helper that polls the same running||finishing gate the
controller's admission check reads, and use it at the three sites that
submit-or-assert right after a TurnDone. The eventual-idle contract is
still asserted (waitIdle fails the test if the gate never reopens);
only the false "instantly idle / instantly admissible" assumption is
removed, matching finishGuardedTurn's documented semantics.

Verified: the affected tests pass 30x consecutively, and two full
whole-repo test runs (the load condition that reproduced the hang) are
clean.

Product-side note, deliberately out of scope here: runGuarded dropping
a submit that lands inside the finishing window is silent. A real
frontend submitting immediately on TurnDone could in principle lose a
turn the same way; whether that admission gap should queue-or-retry
instead of dropping deserves its own change with a chat-runtime race
audit.
2026-07-11 06:13:50 +00:00
SivanCola bd6f75b673 fix(plugin): stage and verify plugin updates before swapping them in
Round-5 review follow-up: replaceCopiedPlugin deleted the previous
install before copying, and the round-4 capability verification deleted
the fresh copy on mismatch — so a replace=true update to a package that
fails verification destroyed the working version while its registration
survived, leaving an enabled plugin with no root.

installCopiedPlugin now copies into a staging directory beside the
target, runs the capability verification there, and only then swaps the
staged tree into place via a backup-protected rename (same parent dir,
so each rename is atomic; the backup restores on a failed swap and stale
backups from interrupted runs are cleaned first). Any failure before the
swap leaves an existing installation untouched.

Regression installs a valid version, then applies a replace=true update
whose command symlink escapes the package: the update fails, the
previous version's files, version, and registration all survive, and no
staging or backup trees are left behind.
2026-07-11 06:13:02 +00:00
Sivan 6969fa3a78 feat(desktop): unify footer decision surface for approvals and asks
Replace one-click approvals with select-then-confirm decision shelves so
tool approval, plan approval, ask, and clear-context share one footer surface.
Keep the composer mounted under a hidden host to preserve drafts, pause model
work timing while waiting on the user, and surface waiting_confirmation as an
amber pill in the compact project tree.
2026-07-11 14:10:30 +08:00
SivanCola 22008f240d fix(desktop): root try-run tools to the active workspace; guard update/delete identity
Second-round review findings on the subagent profiles page:

1. Try-run tools now resolve relative paths against the active tab's
   workspace root. The registry was assembled from compile-time builtins
   plus Confine* overrides — which bound the sandbox/confinement roots
   but never set workDir, so read_file/ls/glob/grep/code_index and
   read-only bash resolved relative paths against the desktop process
   CWD. In a multi-workspace session a try run could inspect (and send
   to the provider) a different project than the one on screen. The
   parent set now comes from builtin.Workspace{Dir: root, ...}.Tools() —
   the same per-workspace assembly boot uses for desktop tabs — before
   the read-only registry is applied. Regression test runs with process
   CWD deliberately different from the workspace root and asserts
   relative read_file/ls resolve inside the root.

2. editableSubagentProfile now also requires runAs=subagent. A
   hand-authored manual-invocation INLINE skill carries only
   editor-managed frontmatter keys, so it passed the previous check and
   UpdateSubagentProfile would rewrite it with runAs: subagent —
   silently converting an inline playbook into an isolated subagent.

3. DeleteSubagentProfile re-resolves its target and applies the full
   profile-identity check before deleting. It previously called the
   generic DeleteSkill directly, so a stale UI list or a direct bridge
   call could remove an unrelated user skill that merely matched
   name+scope.
2026-07-11 06:00:39 +00:00
SivanCola 8cf676e3f3 fix(plugin): keep copy-mode installs faithful to the approved plan
Round-4 review follow-up: discovery (skills and commands) follows
symlinks, but copyDir skipped every symlink — its comment even claimed
otherwise — so a package whose command was an in-tree symlinked alias
planned and reported N capabilities while the installed copy resolved to
fewer, silently.

- copyDir now materializes a symlink as its resolved content only when
  it points at a regular file inside the source tree; escaping, broken,
  and directory links are still never followed.
- Copy-mode plugin installs re-parse the installed tree and require its
  capability counts to match the approved plan; a mismatch removes the
  copy and fails the action with a mode=link / fix-the-package hint, so
  anything materialization cannot cover fails closed instead of
  shrinking the install.

Regressions cover both paths (in-root alias survives; out-of-tree link
refuses and cleans up), skipping on platforms without symlink support.
2026-07-11 05:32:52 +00:00
SivanCola ce18d20f88 fix: harden sibling frontmatter writers, review CLI confinement, test deadlines
Repo-wide sweep follow-ups from the subagent-profiles review (same defect
shapes, different sites):

1. internal/memory/store.go render() emitted hand-concatenated
   frontmatter, so a memory title or description containing ": ", '#',
   or quotes produced unparseable YAML; frontmatter.Split then returned
   an empty map and the reloaded memory silently lost its name, title,
   and type. Now marshaled via yaml.v3 (indent 2, struct field order),
   which renders byte-identically to the old format for plain values —
   pinned by TestRenderPlainValuesKeepLegacyBytes — so existing files
   rewritten on save do not churn, and metacharacter values round-trip
   (TestRenderEscapesYAMLMetacharacters).

2. desktop/memory_suggestions.go had its own string-concatenation skill
   file renderer with the same flaw in the free-text description; it now
   delegates to skill.RenderSkillFile (yaml-escaped), deleting the
   duplicate helper.

3. internal/cli/review.go built its subagent registry from zero-value
   (unconfined) reader tools and an empty bash sandbox spec, so
   `reasonix review` ignored the user's [sandbox] forbid_read roots and
   bash mode. The registry now replaces init-time defaults with confined
   instances mirroring boot's addBuiltins (ConfineReaders/ConfineSearch/
   ConfineBash from the loaded config), verified by a behavioral test
   that a forbid_read path refuses to read through the registry.

4. internal/control test files used 2-second wall-clock deadlines for
   "wait for expected event" assertions; under whole-repo parallel test
   load these flake — TestSubmitUserTurnBypassesCommandDispatch was
   reproduced failing on a clean origin/main-v2 worktree with zero PR
   changes, and CI's race job hit it and
   TestCancelClearsPendingApprovalRuntimeStatus across two runs. All 60
   such sites (verified positive waits — every timeout branch fails the
   test; none assert absence) are raised to 30s: correct code completes
   in milliseconds, the deadline only bounds a genuine hang, so the
   fail-fast property is kept without the scheduling-noise false
   positives.

Out of scope, unchanged: internal/skill/skill.go stubBody writes a fixed
literal description with a regex-validated name (no injection surface).
2026-07-11 05:30:01 +00:00
SivanCola 54b1bf9778 Merge pull request #6309 from SivanCola/fix/auth-open-redirect
fix(site): validate next redirects by parsed origin in auth.js / 按解析后的 origin 校验登录跳转,修复开放重定向
2026-07-11 13:14:06 +08:00
SivanCola feff9f4da5 fix(plugin): pin GitHub plugin installs to the approved commit
Round-3 review follow-ups on the commands mapping:

- P1: plan and apply each cloned the source separately, so a repo that
  moved between the approved plan and the apply resolution could install
  content the approval never covered (and the optional planId echo could
  not catch the apply call's own second clone). pluginSource now reports
  the resolved commit for git sources; the plan records it on the action
  — entering the plan ID, so approvals fingerprint the exact snapshot —
  and apply pins a diverged clone back to the approved commit via a
  full-SHA fetch + detached checkout, refusing with a re-plan error when
  the snapshot is no longer reachable. Regression covers the unpinnable
  drift refusal end to end.
- P2: the command-dir adoption gate capped recursion at 5 levels while
  the runtime loader walks unbounded, so a plugin whose only command sat
  deeper was never adopted. The gate now delegates to internal/command
  itself — adoption and runtime discovery share one implementation and
  cannot diverge; a six-level nesting regression pins it.
2026-07-11 04:58:20 +00:00
SivanCola 2b7e573095 ci: run site node:test suite on every PR
The site/ auth client's redirect-validation tests (safeNext) had an npm
test script but no CI job invoked it, so the security tests never ran on
PRs. Add a `site` job that runs `npm test`. The tests use only Node
builtins, so the job needs no npm install.
2026-07-11 04:51:47 +00:00
SivanCola 0f7649af67 fix(desktop): harden subagent profile editing, try-run, and overrides
Address four review findings on the subagent profiles page:

1. Try It is now read-only and workspace-confined. It previously
   registered zero-value (unconfined) file tools with no permission
   gate, letting a try run write outside the workspace unapproved. It
   now resolves config against the active tab's workspace root
   (LoadForRoot, not process CWD), replaces init-time tool defaults
   with the same confined instances boot's addBuiltins installs
   (ConfineWriters/Readers/Search/Bash/WebFetch), builds the registry
   via ReadOnlySubagentToolRegistry (writers stripped, bash under the
   plan-mode safe policy), and runs under the headless permission gate
   so configured deny rules apply. A settings-page preview has no UI to
   answer approval prompts, so read-only is the honest posture; the
   placeholder copy now says so.

2. Editing is limited to profiles this page can round-trip. The editor
   only carries name/description/color/invocation/runAs/model/effort/
   allowed-tools; saving a hand-authored skill would silently drop
   anything else — most dangerously `read-only`, which boot.go uses to
   pick the read-only tool registry, so an edit could turn a read-only
   agent writable. It also pre-filled the form from the runtime Body
   (references/ + scripts/ expanded), baking that content into the main
   file on save. The frontend now lists only invocation=manual profiles
   as editable, and UpdateSubagentProfile refuses (server-side) any
   skill with unmanaged frontmatter keys or a references/ or scripts/
   sibling directory.

3. RenderSkillFile emits frontmatter via yaml.v3 instead of string
   concatenation. Free text containing ": " (e.g. "Review code: focus
   on security"), '#', quotes, or newlines produced unparseable YAML;
   frontmatter.Split then returned an empty map and the loader silently
   reset runAs to inline and invocation to auto — dissolving both the
   isolation boundary and the no-autodiscovery guarantee. Regression
   tests cover colon/hash/quote/newline/list-lookalike inputs.

4. Per-name model/effort overrides are alias-aware end to end. Runtime
   dispatch resolves underscore/hyphen key variants (security_review ⇄
   security-review); the UI read exact keys only, so a legacy alias
   entry displayed as "inherit default", a clear left it silently
   active, and effort validation could check the wrong model. boot's
   key-alias helper is now exported (SubagentModelKeys) and used by the
   view populate, the effort validator, and set/clear, which sweep all
   alias entries.

Repo sweep for sibling defects: internal/memory/store.go's render()
hand-builds frontmatter and a colon in a memory title breaks its parse
the same way (pre-existing, different subsystem — follow-up, not
fixed here); `reasonix review` CLI builds unconfined zero-value readers
(pre-existing, read-only posture — follow-up); no other exact-key
SubagentModels/SubagentEfforts lookups remain (task has no alias
variants; other sites iterate existing keys).
2026-07-11 04:46:31 +00:00
SivanCola c954b8c079 fix(site): return absolute href from safeNext to close dot-segment escape
The same-origin branch returned u.pathname + u.search + u.hash. Inputs
like /.//evil.example or /a/..//evil.example resolve to a same-origin URL
whose pathname is "//evil.example"; handing that bare pathname to
location.href re-parses it as a protocol-relative URL and navigates to
evil.example. Return the fully-serialized u.href instead — it keeps the
origin attached, so the assignment can never leave reasonix.io.

Added end-to-end regression tests that re-resolve the returned value and
assert it stays on-origin, covering /.//, /a/..//, /..//, and /%2e//.
2026-07-11 04:44:12 +00:00
SivanCola 2ef71c8165 fix(plugin): plan GitHub plugin installs from the real source tree
Review follow-ups on the commands mapping:

- P1: planGitHubPluginPackage assembled a sparse temp dir holding only
  the fetched manifest, so convention-discovered capabilities (skills/,
  commands/) and their warnings were invisible at plan time while apply
  cloned the full repo — the approved plan under-described what actually
  got installed. Plan and apply now resolve the source through one
  shared pluginSource resolver (shallow clone for GitHub), making the
  approval contract hold by construction; a plan/apply consistency
  regression covers it.
- P2: dirContainsCommandMd only looked at the top level, so a plugin
  whose commands all live in namespace subdirs (commands/git/commit.md)
  was never adopted even though the runtime loader walks them. The
  adoption gate now recurses (bounded depth, hidden dirs skipped).
- P3: appendInventoryText's empty-inventory fallback ignored commands, so
  a commands-only plugin printed its command list followed by
  "capabilities: no detailed inventory available".

Verified: reviewer's repro now plans skills=6 commands=11 for
github.com/OthmanAdi/planning-with-files with zero warnings.
2026-07-11 04:19:24 +00:00
SivanCola fdc69aff5e fix(site): parse next redirects via new URL() instead of raw prefix checks
The raw-string prefix check in safeNext() could still be bypassed:
URLSearchParams decodes percent-encoding, so ?next=/%09/evil.example
arrives as "/\t/evil.example" — the tab passes the "/" + not-"/"-or-"\\"
prefix check, but a browser strips ASCII tab/CR/LF while parsing the URL
for navigation, collapsing it to "//evil.example", a protocol-relative
redirect off-site. %0A and %0D behave the same way.

Replace the prefix check with new URL(next, origin) and validate the
*parsed* origin/host, which applies the same normalization a browser
applies before navigating, closing this class of encoding bypass rather
than patching one more special case.

Extracted safeNext() into safe-next.js (a pure function with no DOM/
import.meta dependency) so it's unit-testable with node:test, and added
regression coverage for the tab/CR/LF-smuggled case plus the existing
protocol-relative and backslash bypasses. Run via `npm run test` in
site/.
2026-07-11 04:17:28 +00:00
SivanCola d38945513c feat(plugin): map Claude plugin commands to custom slash commands
Claude plugins like planning-with-files ship their primary interface as
commands/ (flat <name>.md prompt templates invoked as /<name>), which
Reasonix previously dropped with a "does not map yet" warning — the
plugin installed but /plan, /pwf etc. never appeared, reading to users
as a failed install (discussion #5762).

A Claude command is exactly Reasonix's custom slash command shape
(internal/command: frontmatter description/argument-hint, $ARGUMENTS /
$1..$N substitution, subdir namespacing), so map the capability instead
of warning about it:

- pluginpkg: Manifest gains Commands (explicit "commands" list in any
  manifest kind; claude plugins adopt conventional commands/ and
  .claude/commands/ automatically, even alongside explicit skills).
  Inventory/CapabilityCounts/describe now report commands, loaded via
  internal/command so names can never drift from runtime behavior.
- config: CommandDirsForRoot prepends enabled plugin packages' command
  roots at the lowest priority — boot and hot ReloadCommands both pick
  them up, and a user- or project-authored command wins name clashes.
- cli/installsource: plugin verify checks command roots; install plans
  carry commands/commandCount; plugin show lists commands.
- desktop: plugin view exposes commands count + details; capabilities
  panel renders a Commands section (en/zh/zh-TW strings).
- docs: EN/zh-CN plugin docs describe the mapping; commands removed
  from the unmapped-capabilities warning list (agents, hooks/hooks.json,
  .mcp.json still warn).

Verified end-to-end against planning-with-files v3.4.0: installs with
zero warnings, 6 skills + 11 commands, /plan invocable.
2026-07-10 17:58:13 +00:00
SivanCola 6eed8f210a fix(site): reject protocol-relative next redirects in auth.js
safeNext() allowed any value starting with "/", including "//host" and
"/\host" — both are protocol-relative URLs that a browser resolves to
a different origin when assigned to location.href, letting an
attacker craft a post-login redirect off-site (e.g.
?next=//evil.example). Require the second character to not be "/" or
"\" so only true same-origin paths pass; absolute reasonix.io URLs
still go through the existing https + host check below.
2026-07-10 17:45:12 +00:00
SivanCola 2d9b164540 feat(desktop): add subagent profile settings page / 新增子智能体设置页
Add a "子智能体" (Subagents) settings surface backed by the existing
Skills system: a subagent profile is a skill file with `runAs: subagent`
frontmatter plus a new `invocation: manual` flag that excludes it from
the pinned system-prompt index, so the model never auto-discovers or
invokes a profile the user configured for their own deliberate use
(still invocable explicitly via /<name>).

Backend:
- internal/skill: new `color`/`invocation` frontmatter fields, exported
  RenderSkillFile/SkillFileOptions, Store.UpdateContent/Delete, and an
  index.go filter that keeps manual-invocation skills out of the
  cache-stable Skills index.
- internal/control: Capabilities gains CreateSkill/UpdateSkill/DeleteSkill
  so desktop can author skills through the live store.
- internal/agent: export AlwaysHiddenSubagentTools for tool-picker use.
- desktop: new subagents_app.go (Create/Update/Delete/Try + tool
  enumeration), SkillView extended with Model/Effort/AllowedTools/Color/
  Invocation/Body/ConfiguredModel/ConfiguredEffort, and per-name
  SetSubagentProfileModel/Effort setters so built-in rows can override
  their model/effort (the only lever available for built-ins, which have
  no editable frontmatter file).
- desktop/subagents_app.go: TrySubagentProfile runs a profile once in
  full isolation, mirroring internal/cli/review.go's standalone
  provider+registry construction — it never touches Controller.RunSkill
  or the chat-turn dispatch path.

Frontend:
- New SubagentsPanel.tsx: list page (built-in read-only section + custom
  section with edit/delete), create/edit form (name, color swatches,
  model/effort, description, tool-scope picker, system prompt, scope,
  try-it), lazy-loaded like the existing Skills/MCP pages.
- New "Subagents" tab wired into SettingsPanel.tsx between Skills and
  Plugins; ModelPicker/allRefs/toRef/EFFORT_PRESETS exported for reuse.
- i18n additions across en/zh/zh-TW.

Guardrails (from an adversarial design review before implementation):
- Reject a profile name colliding with a built-in subagent name — the
  Store's dedup rules would otherwise let a same-named user file
  silently shadow the built-in everywhere.
- invocation=manual keeps every profile created here out of the pinned
  Skills index (and its shared, capped character budget), closing both
  an unwanted-autonomy path and a prefix-bloat path in one field.

Known limitations (deliberately out of scope for this PR):
- /<name> still inlines a subagent skill's body rather than isolating it
  (existing behavior in internal/control/input.go); changing that
  touches the protected Chat Runtime critical path and is left for a
  separate, narrower change.
- The "available tools" picker only lists compile-time builtins, not
  MCP/plugin tools (those are per-session/connection).

Cache-impact: internal/skill/index.go's render loop now skips
invocation=manual entries. Existing skills default to invocation="auto"
and render identical bytes to before (verified by
TestApplyIndexMandatesInlineButRestrainsSubagent-style existing
coverage plus new TestManualInvocationSkillExcludedFromIndex). The
pinned prefix only changes when a user actually creates/edits/deletes a
subagent profile through this new UI — the same trigger condition
`install_skill` already has today, not a new source of turn-to-turn
churn.
2026-07-10 17:43:14 +00:00
SivanCola 0939c1de51 Merge pull request #6227 from ttmouse/fix/mcp-image-result-6169
fix(plugin): include MCP image content in tool results
2026-07-11 00:46:04 +08:00
SivanCola 2e854a5cb7 fix(mcp): carry tool result images structurally instead of inline markdown
Rework of the previous two commits, addressing the review blockers:

- Tool output text passes truncateToolOutput (32KB head+tail splice)
  before providers see it, so markdown-inlined base64 larger than the
  budget was spliced and the extraction regex captured corrupted
  payloads across the gap - an API 400 for every real screenshot.
  Images now ride outside the text: parseToolResult returns validated
  data URLs (mime whitelist, 4MB/5-image budget, base64 check) plus a
  short text placeholder, remoteTool implements the new tool.ImageTool
  optional interface, and the agent stores the images on the RoleTool
  session message untouched by truncation.
- The OpenAI API rejects image_url parts under role "tool"; tool images
  are now injected as a user message after the turn's tool-result run.
- Non-vision models (e.g. DeepSeek) no longer receive raw base64 text;
  they see only the placeholder, matching the existing user-image gate.

Anthropic keeps the tool_result array-content shape (text + image
blocks); text-only results serialize byte-identical to before, guarded
by regression tests on both providers.
2026-07-10 16:32:24 +00:00
SivanCola a7b32c1ee3 Merge remote-tracking branch 'origin/main-v2' into wt1 2026-07-10 16:31:48 +00:00
Li-Charles-One 595d79849b feat(creation): polish sidebar/dock alignment and chat scrollbar
Narrow Creation sidebar/right-dock defaults, keep 文件/改动 tabs self-centered
without overlapping Windows caption controls, align brand logo with the new-chat
icon baseline, and replace the native transcript scrollbar with a proximity-aware
custom rail. Also reopen the right dock into files/changed (never invisible context).
2026-07-10 19:45:23 +08:00
Li-Charles-One 0917090d82 feat(creation): prioritize session changes in workspace dock
Show session and uncommitted workspace changes before a collapsed commit history in the Creation Changes view, while keeping classic and workbench behavior unchanged.
2026-07-10 12:48:42 +08:00
Li-Charles-One 242194db5f polish(creation): workspace tree polish and quieter topicbar chrome
Align the Creation workspace file tree with the left project-tree language, and reduce topicbar noise by hiding the duplicate Changes control and demoting Command to a utility icon.
2026-07-10 12:15:16 +08:00
ttmouse fdbf7da506 fix(provider): structurally pass MCP tool result images to vision models
parseToolResult converts MCP type:image content to markdown data URIs,
but both the OpenAI and Anthropic providers were sending tool result
content as plain text, so vision models never received proper image
blocks and instead saw raw base64 data.

Now the provider layer detects the markdown image pattern and converts
it to structured image content:
- Anthropic: tool_result.content becomes an array of text + image
  source blocks (tool_result supports array content per API spec)
- OpenAI: tool message content becomes an array of text + image_url
  content parts

The shared SplitToolResultContent helper extracts markdown images
from tool result text and returns text parts + image metadata.

Refs #6169
2026-07-09 16:11:09 +08:00
ttmouse 238e0afdf3 fix(plugin): include MCP image content in tool results (#6169)
parseToolResult only handled MCP content items of type 'text',
silently dropping type 'image' items that carry base64-encoded
image data. This caused MCP tools returning images (e.g.
photoshop_get_preview, take_screenshot) to have their images
removed before reaching the LLM.

Now parseToolResult recognizes type 'image' content and converts
it to a markdown image reference (data URI) so the LLM can see
it.
2026-07-09 09:11:22 +08:00
h4rK8s 21938cd97c fix(cli): recognize shell-escaped pasted image paths 2026-07-06 11:57:03 +08:00
1597 changed files with 320207 additions and 38063 deletions

No files matched your search

+14
View File
@@ -1,3 +1,17 @@
# 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-preview.yml @SivanCola @esengine
/.github/workflows/release-cli-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-desktop-trigger.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
+215 -11
View File
@@ -14,24 +14,94 @@ 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: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
# 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)
@@ -42,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
@@ -56,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/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
@@ -70,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
@@ -79,15 +164,39 @@ jobs:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
# 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:
@@ -147,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 ./...
@@ -163,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:
@@ -206,11 +341,25 @@ jobs:
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: test (Windows-only path semantics)
timeout-minutes: 10
run: go test . -run 'OnWindows$' -v
- name: test (Windows desktop and update helper)
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
@@ -226,7 +375,60 @@ 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/release-preview.yml \
.github/workflows/release-cli-trigger.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml
bash scripts/release-workflows.test.sh
# 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:
working-directory: site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: test
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:
@@ -244,6 +446,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"
+18
View File
@@ -31,4 +31,22 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npm run typecheck
npm test
npx wrangler deploy
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
# sentinel can push alerts (see runIngestSentinel). Managed here so
# nobody needs local Cloudflare credentials; when the repo secret is
# unset the sentinel stays log-only.
- name: Sync alert webhook secret
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
run: |
if [ -z "$ALERT_WEBHOOK" ]; then
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
+1 -1
View File
@@ -3,7 +3,7 @@ name: Deploy site
on:
push:
branches: [main-v2]
paths: ['site/**', '.github/workflows/pages.yml']
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
+132
View File
@@ -0,0 +1,132 @@
name: Prepare release notes
on:
workflow_dispatch:
inputs:
version:
description: "Exact release version without a tag prefix (for example 1.18.0 or 1.19.0-preview.3)"
required: true
type: string
from_tag:
description: "Previous release tag (defaults to the newest catalog entry)"
required: false
type: string
target_pr:
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
required: false
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
VERSION: ${{ inputs.version }}
TARGET_PR: ${{ inputs.target_pr }}
run: |
set -euo pipefail
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 [ -n "$TARGET_PR" ]; then
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::target_pr must be a positive pull request number"
exit 1
fi
mapfile -t pr_state < <(
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
--json state,baseRefName,headRefName,isCrossRepository \
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
)
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
echo "::error::target_pr must be an open pull request into main-v2"
exit 1
fi
if [ "${pr_state[3]}" != "false" ]; then
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
exit 1
fi
branch="${pr_state[2]}"
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
exit 1
fi
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
else
branch="release-notes/v${VERSION#v}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
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
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_PR=" >> "$GITHUB_ENV"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
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/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:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-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 [ -n "$RELEASE_NOTES_PR" ]; then
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md
fi
+39
View File
@@ -0,0 +1,39 @@
name: Relay CLI prerelease
run-name: Relay CLI prerelease ${{ github.ref_name }}
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
# carries only the immutable candidate tag and its classified channel into the
# protected release control plane.
on:
push:
tags: ["v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected CLI control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release.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;
}
const tag = process.env.RELEASE_TAG;
const preview = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: preview ? 'release-preview.yml' : 'release.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: preview ? { tag } : { channel: 'stable', tag },
});
@@ -0,0 +1,43 @@
name: Relay desktop prerelease
run-name: Relay desktop prerelease ${{ github.ref_name }}
# Keep the production signing build on protected main-v2. The tag-triggered
# relay carries only the immutable candidate tag into the protected workflow.
on:
push:
tags: ["desktop-v*-*"]
permissions:
actions: write
contents: read
jobs:
relay:
name: dispatch protected desktop control plane
runs-on: ubuntu-latest
steps:
- name: Dispatch release-desktop.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;
}
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
return;
}
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-desktop.yml',
ref: process.env.CONTROL_PLANE_REF,
inputs: {
channel: 'stable',
tag: process.env.RELEASE_TAG,
},
});
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
name: Release notes
on:
pull_request:
paths:
- "release-notes/**"
- "scripts/release-notes*.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
+133 -62
View File
@@ -1,49 +1,137 @@
name: Release npm
# npm line. Tag namespace `npm-v<semver>` triggers this — plain `v<semver>` tags
# are the CLI binary + Homebrew release (release.yml) and intentionally do NOT
# trigger here (GitHub glob `npm-v*` does not match `v*`). Splitting npm onto its
# own tag lets a prerelease ship to npm's `next` channel without touching the
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
# its rc). Bump with:
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
# 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 an approved stable/next version when tag creation is restricted. 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.
# Manual workflow_dispatch recovers only an approved Stable version from an
# existing tag. Public canary publication is owned by release-preview.yml so its
# version remains aligned with CLI, Desktop, and the immutable release marker.
# 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 publisher verifies every immutable package and
# advances each public dist-tag only when the candidate is newer.
on:
push:
tags: ['npm-v*']
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:
description: "stable: existing npm tag to publish (for example npm-v1.18.0)"
required: false
type: string
workflow_call:
inputs:
channel:
description: "Publish channel selected by the approved release orchestrator"
required: true
type: string
base_version:
description: "Version to publish"
required: true
type: string
tag:
description: "Existing npm tag to check out for stable publication"
required: false
default: ""
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by an approved release orchestrator"
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
required: false
default: stable
type: string
preview_number:
description: "Canonical Preview ordinal supplied by release-preview.yml"
required: false
default: ""
type: string
permissions:
contents: read
concurrency:
# Serialize every publisher for one npm dist-tag. In particular, canary
# remains ordered even if another approved orchestrator is added later.
group: release-npm-${{ inputs.channel || 'next' }}
cancel-in-progress: false
jobs:
orchestration-guard:
name: verify approved orchestrator
if: ${{ inputs.orchestrated }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_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 }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
CALLER_SHA: ${{ github.sha }}
APPROVED_CLI_TAG: ${{ inputs.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
APPROVED_CHANNEL: ${{ inputs.orchestrator }}
RELEASE_TAG: ${{ inputs.approved_cli_tag }}
VERIFY_RELEASE_CHECKOUT: false
run: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
release-gate:
name: approve standalone npm release
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
run: echo "Approved standalone npm 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')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -53,13 +141,14 @@ jobs:
npm:
name: publish npm packages
needs: cache-guard
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
# A tag push (npm-v*) or manual stable dispatch publishes a stable/next
# release and goes through the `release` environment (esengine approves);
# canary dispatches run free.
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }}
# Orchestrated releases have already passed their GitHub environment
# approval. Direct prereleases and manual Stable recovery pass release-gate.
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -68,47 +157,29 @@ 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
# Tag push uses the npm-v* tag. The Preview orchestrator synthesizes a
# <base>-canary.<preview_number> version; a manual Stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
- name: Resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
base="${{ inputs.base_version }}"; base="${base#v}"
if [ "${{ inputs.channel }}" = "canary" ]; then
echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
else
echo "arg=v${base}" >> "$GITHUB_OUTPUT"
fi
else
echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
env:
EVENT_NAME: ${{ github.event_name }}
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
IN_CHANNEL: ${{ inputs.channel }}
IN_BASE_VERSION: ${{ inputs.base_version }}
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.approved_cli_tag }}
APPROVED_SHA: ${{ inputs.approved_sha }}
run: bash scripts/verify-release-tag.sh
- name: Publish or recover immutable npm packages
env:
VERSION_ARG: ${{ steps.ver.outputs.arg }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# 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
run: |
set -euo pipefail
version="${{ steps.ver.outputs.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
run: node npm/build.mjs "$VERSION_ARG" --publish
+219
View File
@@ -0,0 +1,219 @@
name: Release preview
run-name: Release preview ${{ inputs.tag }}
# One protected Preview event owns the public CLI, Desktop, npm, and changelog
# identities. The CLI tag supplies the canonical preview ordinal; child
# publishers retain their native version syntax but use that same ordinal.
on:
workflow_dispatch:
inputs:
tag:
description: "Existing protected CLI Preview tag, e.g. v1.19.0-preview.3"
required: true
type: string
recovery:
description: "Recover an existing partially published Preview from main-v2 history"
required: false
default: false
type: boolean
concurrency:
# Every Preview version advances the same public CLI, Desktop, and npm
# channels. Keep the whole all-surface event serialized across versions.
group: preview-release
cancel-in-progress: false
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate Preview release event
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
base_version: ${{ steps.release.outputs.base_version }}
preview_number: ${{ steps.release.outputs.preview_number }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve Preview release
id: release
env:
RELEASE_TAG: ${{ inputs.tag }}
ALLOW_PREVIEW_RECOVERY: ${{ inputs.recovery }}
run: bash scripts/resolve-preview-release.sh
- name: Validate exact reviewed Preview notes
env:
VERSION: ${{ steps.release.outputs.version }}
RELEASE_SHA: ${{ steps.release.outputs.sha }}
run: |
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
node scripts/release-event.mjs generate \
--version "$VERSION" \
--sha "$RELEASE_SHA" \
--published-at 2000-01-01T00:00:00.000Z \
--output /tmp/release-event.json
- 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
authorize:
name: approve Preview release
needs: preflight
runs-on: ubuntu-latest
environment: canary
outputs:
version: ${{ steps.approved.outputs.version }}
base_version: ${{ steps.approved.outputs.base_version }}
preview_number: ${{ steps.approved.outputs.preview_number }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved Preview event
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
BASE_VERSION: ${{ needs.preflight.outputs.base_version }}
PREVIEW_NUMBER: ${{ needs.preflight.outputs.preview_number }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "base_version=$BASE_VERSION"
echo "preview_number=$PREVIEW_NUMBER"
echo "cli_tag=$CLI_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved Preview release $VERSION at $RELEASE_SHA"
signpath-preflight:
name: verify Preview SignPath control plane
needs: authorize
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight: true
secrets: inherit
cli:
name: publish CLI Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release.yml
with:
channel: preview
tag: ${{ needs.authorize.outputs.cli_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
allow_preview_recovery: ${{ inputs.recovery }}
secrets: inherit
npm:
name: publish npm Canary
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-npm.yml
with:
channel: canary
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
secrets: inherit
desktop:
name: publish Desktop Preview
needs: [authorize, signpath-preflight]
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
uses: ./.github/workflows/release-desktop.yml
with:
channel: preview
base_version: ${{ needs.authorize.outputs.base_version }}
preview_number: ${{ needs.authorize.outputs.preview_number }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
orchestrator: preview
signing_preflight_verified: true
secrets: inherit
postflight:
name: publish Preview release record
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every Preview publisher to succeed
env:
CLI_RESULT: ${{ needs.cli.result }}
NPM_RESULT: ${{ needs.npm.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
run: |
set -euo pipefail
for surface in CLI NPM DESKTOP; do
result_var="${surface}_RESULT"
if [ "${!result_var}" != "success" ]; then
echo "::error::$surface Preview publisher result is ${!result_var}, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Upload immutable public release marker
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
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 Preview 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,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',
},
});
+266
View File
@@ -0,0 +1,266 @@
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; 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 current main-v2 commit, and one GitHub approval releases
# every channel. After approval, a zero-publication Windows preflight verifies
# both architectures and signing stages before any public channel starts.
# 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:
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 }}
cancel-in-progress: false
# Reusable release workflows can only reduce caller permissions, so the
# orchestrator grants the union needed by CLI/Desktop publication.
permissions:
actions: write
contents: write
jobs:
preflight:
name: validate stable release set
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
npm_tag: ${{ steps.release.outputs.npm_tag }}
desktop_tag: ${{ steps.release.outputs.desktop_tag }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# A recovery dispatch uses the fixed workflow/scripts from protected
# main-v2. Publishers still check out the immutable approved tag SHA.
ref: ${{ github.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Resolve stable release
id: release
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
run: bash scripts/resolve-stable-release.sh
- 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
authorize:
name: approve stable release
needs: preflight
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
outputs:
version: ${{ steps.approved.outputs.version }}
cli_tag: ${{ steps.approved.outputs.cli_tag }}
npm_tag: ${{ steps.approved.outputs.npm_tag }}
desktop_tag: ${{ steps.approved.outputs.desktop_tag }}
sha: ${{ steps.approved.outputs.sha }}
steps:
- name: Record approved release
id: approved
env:
VERSION: ${{ needs.preflight.outputs.version }}
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
NPM_TAG: ${{ needs.preflight.outputs.npm_tag }}
DESKTOP_TAG: ${{ needs.preflight.outputs.desktop_tag }}
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
run: |
{
echo "version=$VERSION"
echo "cli_tag=$CLI_TAG"
echo "npm_tag=$NPM_TAG"
echo "desktop_tag=$DESKTOP_TAG"
echo "sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
echo "Approved stable release $VERSION at $RELEASE_SHA"
cli:
name: publish CLI and Homebrew
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 }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit
npm:
name: publish npm
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
base_version: ${{ needs.authorize.outputs.version }}
tag: ${{ needs.authorize.outputs.npm_tag }}
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
approved_sha: ${{ needs.authorize.outputs.sha }}
orchestrated: true
secrets: inherit
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
tag: ${{ needs.authorize.outputs.desktop_tag }}
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:
name: verify stable release artifacts
needs: [authorize, cli, npm, desktop]
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
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 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::$channel stable publisher result is $result, expected success"
exit 1
fi
done
- uses: actions/checkout@v7
with:
# Postflight belongs to the trusted control plane, not the old build
# candidate, which may predate this verifier.
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Verify public artifacts and npm latest
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ needs.authorize.outputs.version }}
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[@]}"
+425 -25
View File
@@ -1,25 +1,181 @@
name: Release
# CLI binary line. Tag namespace `v<semver>` triggers this — it builds the
# release archives + checksums, publishes the GitHub release, and updates the
# Homebrew cask. npm is a SEPARATE line (release-npm.yml, `npm-v*`) so an npm
# prerelease can ship without forcing the stable cask, and vice versa; desktop is
# `desktop-v*` (release-desktop.yml). A prerelease `v*-rc*` still builds archives
# and a prerelease GitHub release, but goreleaser auto-skips the cask upload
# (brew has no prerelease channel — see .goreleaser.yaml).
# Native CLI binary line. Stable and Preview releases are called by their
# approved all-surface orchestrators. Manual dispatch remains available only for
# Stable/RC recovery; standalone prereleases remain internal stable-channel
# checkpoints behind the `release` gate.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
channel:
description: "Standalone CLI recovery channel"
required: true
# Public Preview recovery reruns release-preview.yml so every surface and
# its immutable release-event marker stay aligned.
default: stable
type: choice
options: [stable]
tag:
description: "Existing protected Stable/RC CLI tag (for example v1.18.0 or v1.18.0-rc.1)"
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
type: string
approved_cli_tag:
description: "Stable CLI tag recorded by the approved orchestrator"
required: true
type: string
approved_sha:
description: "Immutable commit recorded by the approved orchestrator"
required: true
type: string
orchestrated:
description: "True only when called by an approved release orchestrator"
required: false
default: false
type: boolean
orchestrator:
description: "Trusted release orchestrator: stable or preview"
required: false
default: stable
type: string
allow_preview_recovery:
description: "Allow an approved Preview tag from main-v2 history"
required: false
default: false
type: boolean
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:
contents: read
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Verify caller and approved release ref
env:
ACTUAL_CALLER_WORKFLOW_REF: ${{ github.workflow_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 }}
CALLER_WORKFLOW_SHA: ${{ github.workflow_sha }}
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: |
bash scripts/verify-release-authorization.sh
bash scripts/verify-release-tag.sh
release-gate:
name: approve standalone CLI release
needs: resolve
if: ${{ !inputs.orchestrated }}
runs-on: ubuntu-latest
environment: release
steps:
- env:
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: [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: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -28,33 +184,277 @@ 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
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
# repository homepage points users at the installable app. Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
# 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.
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
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
cache: true
- 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: ${{ 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
env:
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: ${{ needs.resolve.outputs.tag }}
- name: Publish product release notes
env:
GH_TOKEN: ${{ github.token }}
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: ${{ 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 }}
@@ -78,7 +478,7 @@ jobs:
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq '.download_page = "https://reasonix.io/?download=desktop#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
@@ -96,7 +496,7 @@ jobs:
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
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
@@ -132,18 +532,18 @@ jobs:
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the runs race: both workflows wait
# on the release environment independently, and npm dist-tags propagate
# Two distinct states, two responses (the approved orchestrator starts the
# CLI and npm reusable workflows concurrently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm run may still be awaiting its own environment approval;
# release-npm.yml's verify step owns asserting the dist-tag lands.
# The npm job may still be publishing; release-npm.yml's verify step
# owns asserting the dist-tag lands.
- name: Check npm latest dist-tag freshness
env:
TAG: ${{ github.ref_name }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
case "$TAG" in
@@ -169,5 +569,5 @@ jobs:
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
sleep 10
done
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the concurrent npm publish is likely still running or propagating. Monitor the npm job; its verify step asserts the dist-tag lands."
exit 0
+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[@]}"
+74
View File
@@ -0,0 +1,74 @@
name: Update Star History chart
on:
workflow_dispatch:
push:
branches:
- main-v2
paths:
- '.github/workflows/update-star-history.yml'
- 'scripts/update-star-history.mjs'
- 'scripts/update-star-history.test.mjs'
schedule:
- cron: '23 */3 * * *'
permissions:
contents: write
concurrency:
group: update-star-history
cancel-in-progress: false
jobs:
update:
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 chart generator
run: node --test scripts/update-star-history.test.mjs
- name: Generate chart assets
env:
STAR_HISTORY_GITHUB_TOKEN: ${{ secrets.STAR_HISTORY_GITHUB_TOKEN }}
STAR_HISTORY_OUTPUT_DIR: ${{ runner.temp }}/star-history
run: node scripts/update-star-history.mjs
- name: Publish chart assets
env:
OUTPUT_DIR: ${{ runner.temp }}/star-history
TARGET_BRANCH: star-history
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git fetch origin "$TARGET_BRANCH"; then
git switch --force-create "$TARGET_BRANCH" "origin/$TARGET_BRANCH"
else
git switch --create "$TARGET_BRANCH"
fi
mkdir -p assets/star-history
cp "$OUTPUT_DIR/star-history-light.svg" assets/star-history/star-history-light.svg
cp "$OUTPUT_DIR/star-history-dark.svg" assets/star-history/star-history-dark.svg
git add assets/star-history
if git diff --cached --quiet; then
echo "Star History chart is already current."
exit 0
fi
git commit \
-m "docs: update Star History chart" \
-m "Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.
Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data"
git push origin HEAD:"$TARGET_BRANCH"
+11
View File
@@ -48,6 +48,11 @@ node_modules/
!/.reasonix/commands/
!/.reasonix/commands/review.md
# Stray Reasonix state written under a package dir by a test whose RealPath
# resolves to CWD (e.g. remote bootstrap EnsureServe). Never commit these.
internal/**/.reasonix/
desktop/**/.reasonix/
# CodeGraph per-project index (rebuilt locally; never committed)
.codegraph/
@@ -62,3 +67,9 @@ benchmarks/context-maintenance-e2e/run/
# Local scratch
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>
+23
View File
@@ -0,0 +1,23 @@
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-preview.yml
- .github/workflows/release-desktop.yml
fingerprint_files:
- .github/workflows/release-stable.yml
- .github/workflows/release-stable-trigger.yml
- .github/workflows/release-preview.yml
- .github/workflows/release-cli-trigger.yml
- .github/workflows/release-desktop.yml
- .github/workflows/release-desktop-trigger.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
+113 -4
View File
@@ -6,12 +6,121 @@ branch.
## Unreleased
### Added
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
with trust-on-first-use host-key verification, keepalive + exponential-backoff
reconnect, `-L`/`-R` port forwarding, and SFTP file access. `connect`
bootstraps a persistent `reasonix serve` on the remote host and tunnels its
loopback port so the full agent runs remotely. The desktop app adds a
**Settings -> Remote SSH** host manager, a remote file browser/editor, a
port-forwarding panel, and a status-bar connection chip. Linux/macOS remotes.
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
headless serve can bind an ephemeral port and read its auth token from a file
(keeping it out of `ps`).
- Added Claude Code-style searchable CLI pickers for models, providers, and
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
one-shot use and automation.
- Added session-scoped `--allowed-tools`, repeatable `--add-dir`, Claude-compatible
permission modes, flexible `--resume [QUERY]`, and the `--copy` resume escape
hatch.
- Added `/status` details for the active model, effort, cache, Git state,
background jobs, work profile, and provider balance where available.
### Changed
- Agent runtime defaults now leave both executor and dedicated planner tool-call
rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now
come from the user/global config only; project `reasonix.toml` does not
override them.
- Automatic Plan Mode has been retired. Plan Mode is now always entered through
an explicit user choice, and the one-time config v5 upgrade removes legacy
`agent.auto_plan` and `agent.auto_plan_classifier` values so upgraded users
receive the same behavior as new users.
- `Shift+Tab` now cycles CLI safe modes from Ask to Auto to Plan, while YOLO
remains an independent `Ctrl+Y` toggle.
- Model, provider, resume, and approval menus now use consistent row selection;
slash completion, help, aliases, and dispatch share one command registry.
- The full-screen CLI composer now uses theme-accented borders and a slim bar
cursor by default, grows within the available terminal height, scrolls long
drafts independently, and preserves selections across explicit image paste.
- The persistent CLI footer now uses a responsive, theme-aware layout for
interaction state, model, effort, localized work mode, Git identity, cache,
context, compaction headroom, jobs, and balance. Narrow terminals move or
compact complete groups instead of clipping labels.
- CLI clipboard actions now separate terminal-native text paste from explicit
image paste: `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`.
Local transcript copy verifies the native clipboard write, while SSH uses a
clearly labelled OSC 52 fallback.
- Runtime rebuilds after model, effort, or work-mode changes now preserve the
conversation, session permission overrides, additional directories, and
session lease ownership.
- Agent execution now monitors host-observed Todo progress automatically. A
stalled current item receives a recovery nudge after 8 tool-call rounds with
no new completion, unique read, command, or mutation, and pauses with saved
work after 16. Exact repeats do not renew the progress lease; real work does.
Two-level task lists keep the single in_progress contract: the active
sub-step is the only current item while its phase stays pending, and the
phase becomes in_progress to sign off only after all of its sub-steps are
completed. A level-1 sub-step with no phase header above it is rejected.
Executor and planner rounds now use automatic progress management. Retired
`[agent].max_steps` and `planner_max_steps` keys remain parseable for upgrades,
but are ignored and removed by a one-time migration so stale hidden limits
cannot truncate new behavior. One-off CLI and unattended bot limits remain.
### 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
the native window with a recovery prompt instead of appearing not to launch.
Remote Markdown images are fetched by the backend with Reasonix's proxy
configuration instead of bypassing that proxy through the isolated WebView.
- Restored captured-mouse right-click text paste, made composer drag selection
copy through the verified native clipboard path, and kept non-Git footer
telemetry left-aligned without reserving an empty data band.
- Restored stateful MCP behavior after the v1.17.13 regression: user-added
servers work without extra trust settings (including delivery-mode on-demand
calls), repository-provided servers use one exact launch confirmation, and
stdio tools reuse one persistent process so browser sessions survive across
calls without repeated startup latency. The former trust/reverify/catalog
management UI and CLI are removed.
- Localized persistent-footer labels and displayed work-mode values in English,
Simplified Chinese, and Traditional Chinese, while keeping command arguments
stable.
- Restored the `0.53` content boundary: model output, tool output, session
transcripts, recovery branches, and background-job artifacts retain their
original text instead of being rewritten by heuristic secret redaction.
Credential masking remains in key-entry summaries and explicit diagnostic or
session-cleanup paths. Transcript-bearing session/job sidecars are kept
private (`0600`, with private job directories), and the retired
`redact_tool_output` setting is removed with a one-time upgrade notice.
## [1.0.0] — 2026-06-03
+79 -72
View File
@@ -9,6 +9,8 @@
&nbsp;·&nbsp;
<a href="./docs/GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<a href="./docs/ACP.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
@@ -16,11 +18,6 @@
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
> [!IMPORTANT]
> **Reasonix 1.0 is a ground-up rewrite in Go** — this branch (`main-v2`) is the new default and where development happens now.
> The earlier `0.x` TypeScript releases are **legacy**, living on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) branch (maintenance only).
> See the **[migration guide](./docs/MIGRATING.md)**. `npm i -g reasonix` stays the install command — `1.0.0`+ delivers the Go binary, `0.x` is the legacy TS build.
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
@@ -62,6 +59,14 @@
## Install
Choose the path that matches how you want to use Reasonix. The CLI/TUI,
desktop app, and VS Code extension all use the same local Reasonix engine.
### Path A: CLI / TUI
Install the native binary through npm on any supported platform, or use
Homebrew on macOS:
```sh
npm i -g reasonix # any OS; pulls the prebuilt native binary
brew install esengine/reasonix/reasonix # macOS
@@ -70,103 +75,90 @@ brew install esengine/reasonix/reasonix # macOS
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
### Code signing
### Path B: Desktop app
Windows builds are code-signed with a free certificate provided by the
[SignPath Foundation](https://signpath.org/), with signing through
[SignPath.io](https://signpath.io/).
Use the [official download page](https://reasonix.io/?download=desktop#start)
for the latest desktop build.
### Build from source
| Platform | Package | Architecture |
| --- | --- | --- |
| macOS | Universal `.dmg` or `.zip` | Apple Silicon / Intel |
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
| Linux | `.deb` or `.tar.gz` | x64 |
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
### Path C: VS Code extension
Complete Path A first. The extension does not bundle the CLI; it starts your
local `reasonix acp` backend and adds native chat, editor context, tool-call
approvals, model selection, and workspace sessions.
- **VS Code:** [install from Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [install from Open VSX Registry](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **Extension ID:** `SivanLiu.reasonix-agent` · [source and usage guide](https://github.com/SivanCola/reasonix-vscode)
### Path D: Build from source
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
make build # -> bin/reasonix(.exe)
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```
## Quick start
### CLI / TUI
These commands are for the CLI/TUI installed through Path A:
```sh
reasonix setup # config wizard → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env
reasonix # then run /init to generate AGENTS.md (project memory)
reasonix setup # configure a provider and model
reasonix # start an interactive session
reasonix run "implement the TODOs in main.go"
reasonix run --model deepseek-pro "add unit tests for this function"
echo "explain this code" | reasonix run
```
## Configuration
In an interactive session, run `/init` when you want Reasonix to create project
instructions.
A minimal `reasonix.toml` — one provider and a default model — is enough to start:
### Desktop app
```toml
default_model = "deepseek-flash"
Download the installer for your platform from the
[official download page](https://reasonix.io/?download=desktop#start), install
and launch Reasonix, then configure a provider and model in the app. The CLI
commands above are not required for the desktop app.
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
```
Resolution order is **flag > `./reasonix.toml` > the user config file >
built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at
`~/.reasonix/config.toml` on macOS/Linux and
`%AppData%\reasonix\config.toml` on Windows. See
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the
full `config.toml` / `.env` structure. Provider entries name secrets with
`api_key_env`; the secret values themselves live in Reasonix's global
`<Reasonix home>/.env`, shared by CLI and desktop. Project `.env` files are not
provider-key runtime fallbacks, but still feed workspace-scoped, non-provider
`${VAR}` expansion for MCP/plugin settings without importing Reasonix control
variables. Permissions, the sandbox, plugins (MCP), slash
commands, `@` references, and two-model setup are all in the
**[Guide](./docs/GUIDE.md)**.
For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.md)**,
**[Guide](./docs/GUIDE.md)**, and
**[configuration paths](./docs/CONFIG_PATHS.md)**.
## Documentation
- **[Guide](./docs/GUIDE.md)** — configuration, permissions & sandbox, plugins
(MCP), slash commands, `@` references, two-model collaboration.
- **[Bot guide](./docs/BOT_GUIDE.md)** — connect Feishu, Lark, and WeChat bots
from the desktop app, then use approvals, YOLO, and commands from IM.
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
data types, and roadmap.
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
complex requests with context, output boundaries, constraints, and when to ask.
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
names, read-only flags, and schema snapshot guard.
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
TypeScript releases to the 1.0 Go rewrite.
- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit
safety net (Esc-Esc / `/rewind`).
<br/>
- **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) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Migrating from 0.x](./docs/MIGRATING.md)
## Star History
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
</picture>
</a>
<br/>
## Support
If Reasonix has been useful and you'd like to say thanks, you can. It stays a coffee, not a contract — donations don't buy feature priority or change how issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="240"/>
</p>
<br/>
## Acknowledgments
A small list of folks whose work has shaped Reasonix the most — the current top
@@ -202,3 +194,18 @@ for designing the project logo, and to
<br/>
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
</p>
---
<p align="center"><sub><strong>Support this project</strong></sub></p>
If Reasonix has been useful and you'd like to say thanks, you can. It stays a
coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
</p>
+77 -64
View File
@@ -9,18 +9,15 @@
&nbsp;·&nbsp;
<a href="./docs/GUIDE.zh-CN.md">指南</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">规格</a>
<a href="./docs/ACP.zh-CN.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.zh-CN.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
> [!IMPORTANT]
> **Reasonix 1.0 是用 Go 从零重写的版本** —— 本分支(`main-v2`)已是新的默认分支,后续开发都在这里。
> 早期的 `0.x` TypeScript 版本转为 **legacy**,保留在 [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) 分支(仅维护)。
> 详见**[迁移指南](./docs/MIGRATING.md)**。`npm i -g reasonix` 仍是安装命令——`1.0.0`+ 装的是 Go 二进制,`0.x` 是 legacy TS 版。
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
@@ -58,6 +55,13 @@
## 安装
选择适合你的使用路径。CLI/TUI、桌面端和 VS Code 扩展都使用同一套本地
Reasonix 引擎。
### 路径 A:CLI / TUI
任意支持的平台都可以通过 npm 安装原生二进制;macOS 也可以使用 Homebrew:
```sh
npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制
brew install esengine/reasonix/reasonix # macOS
@@ -66,94 +70,89 @@ brew install esengine/reasonix/reasonix # macOS
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
### 代码签名
### 路径 B:桌面端
Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书,
通过 [SignPath.io](https://signpath.io/) 完成签名。
前往[官方下载页](https://reasonix.io/?download=desktop#start)获取最新桌面版本。
### 从源码构建
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
| macOS | 通用 `.dmg` 或 `.zip` | Apple Silicon / Intel |
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
| Linux | `.deb` 或 `.tar.gz` | x64 |
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
[SignPath 基金会](https://signpath.org/) 免费提供。
### 路径 C:VS Code 扩展
请先完成路径 A。扩展不内置 CLI,而是启动本机的 `reasonix acp` 后端,
并提供原生聊天、编辑器上下文、工具调用审批、模型选择和工作区会话。
- **VS Code:** [从 Visual Studio Marketplace 安装](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [从 Open VSX Registry 安装](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **扩展 ID:** `SivanLiu.reasonix-agent` · [源码与使用说明](https://github.com/SivanCola/reasonix-vscode)
### 路径 D:从源码构建
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
make build # -> bin/reasonix(.exe)
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```
## 快速开始
### CLI / TUI
以下命令仅适用于通过路径 A 安装的 CLI/TUI:
```sh
reasonix setup # 配置向导 → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env
reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆)
reasonix setup # 配置 provider 和模型
reasonix # 启动交互式会话
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model deepseek-pro "给这个函数补单元测试"
echo "解释这段代码" | reasonix run
```
## 配置
需要项目指令时,可在交互式会话中运行 `/init`。
一个最小的 `reasonix.toml`——一个 provider 加一个默认模型——就够跑起来:
### 桌面端
```toml
default_model = "deepseek-flash"
从[官方下载页](https://reasonix.io/?download=desktop#start)下载对应系统的安装包,
安装并启动 Reasonix,然后在应用内配置 provider 和模型即可使用。桌面端无需执行
上面的 CLI 命令。
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
```
优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从
**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`,
Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml`
和 `.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在
CLI 与桌面端共用的 Reasonix 全局 `<Reasonix home>/.env`;项目 `.env` 不再作为
provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、
斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。
CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**、
**[指南](./docs/GUIDE.zh-CN.md)** 和
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
## 文档
- **[指南](./docs/GUIDE.zh-CN.md)** —— 配置、权限与沙盒、插件(MCP)、斜杠命令、
`@` 引用、双模型协同。
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网
(Esc-Esc / `/rewind`)。
<br/>
- **开始使用:** [指南](./docs/GUIDE.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) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.zh-CN.md)
- **工程与迁移:** [规格](./docs/SPEC.zh-CN.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md) ·
[工具合约](./docs/TOOL_CONTRACT.zh-CN.md) ·
[从 0.x 迁移](./docs/MIGRATING.zh-CN.md)
## Star 趋势
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
</picture>
</a>
<br/>
## 支持本项目
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature 优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="240"/>
</p>
<br/>
## 致谢
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。
@@ -188,3 +187,17 @@ provider key 的运行时 fallback,但仍会作为当前 workspace 范围内
<br/>
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
</p>
---
<p align="center"><sub><strong>支持本项目</strong></sub></p>
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
</p>
+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
+24 -5
View File
@@ -15,8 +15,8 @@ import (
)
type diffOpts struct {
bin, model, repo, base, testCmd string
maxSteps, timeoutSec, attempts int
bin, model, repo, base, testCmd, profile string
maxSteps, timeoutSec, attempts int
}
type testRef struct{ name, pkg string }
@@ -37,7 +37,11 @@ type pinResult struct {
func runDiff(o diffOpts) string {
srcFiles := changedGoFiles(o.repo, o.base, false)
if len(srcFiles) == 0 {
return "## 🤖 Reasonix e2e — diff test-gen\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n"
profile := o.profile
if profile == "" {
profile = benchmarkProfileBaseline
}
return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
}
pkgs := packagesOf(srcFiles)
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
@@ -80,6 +84,7 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
if o.model != "" {
args = append(args, "--model", o.model)
}
args = appendBenchmarkProfileArgs(args, o.profile)
args = append(args, prompt)
cmd := exec.CommandContext(ctx, o.bin, args...)
cmd.Dir = o.repo
@@ -114,7 +119,7 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
passed: passed, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
}
}
@@ -190,6 +195,7 @@ type diffReport struct {
buildOut string
failing []string
passed bool
profile string
attempt, attempts int
m runMetrics
runErr error
@@ -203,7 +209,11 @@ func renderDiff(r diffReport) string {
if r.passed {
result = "✅ pass"
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen\n\n")
profile := r.profile
if profile == "" {
profile = benchmarkProfileBaseline
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
@@ -223,6 +233,15 @@ func renderDiff(r diffReport) string {
fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens))
fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps)
fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost)
if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 {
fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes)
fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines)
fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall)
fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries)
if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 {
fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs)
}
}
if len(r.failing) > 0 {
fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `"))
}
+36 -6
View File
@@ -37,11 +37,28 @@ type runMetrics struct {
Cost float64 `json:"cost"`
Currency string `json:"currency"`
Compactions int `json:"compactions"`
// Optional Delivery capability counters (omitempty for baseline/old metrics).
ReadinessChecks int `json:"readiness_checks,omitempty"`
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
CapabilityRoutes int `json:"capability_routes,omitempty"`
CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
CapabilityDeclines int `json:"capability_declines,omitempty"`
CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
}
type result struct {
task
runMetrics
Profile string `json:"profile"`
Passed bool
Skipped bool
Note string
@@ -57,12 +74,15 @@ func main() {
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
}
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
model := flag.String("model", "", "provider/model name (default: config default)")
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
@@ -74,11 +94,16 @@ func main() {
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)")
flag.Parse()
profile, err := normalizeBenchmarkProfile(*profileFlag)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if *mode == "diff" {
report := runDiff(diffOpts{
bin: *bin, model: *model, repo: *repo, base: *base,
testCmd: *testCmd, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
})
emit(report, *outMD, "")
return
@@ -103,10 +128,10 @@ func main() {
total := 0
for _, t := range tasks {
if *budget > 0 && total >= *budget {
results = append(results, result{task: t, Skipped: true, Note: "skipped: token budget reached"})
results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"})
continue
}
r := runTask(*bin, *model, t)
r := runTask(*bin, *model, profile, t)
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
}
@@ -178,8 +203,8 @@ func loadTasks(suite string) ([]task, error) {
// runTask copies the task's seed workdir into a temp dir, runs the agent there,
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(bin, model string, t task) result {
r := result{task: t}
func runTask(bin, model, profile string, t task) result {
r := result{task: t, Profile: profile}
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
if err != nil {
@@ -206,6 +231,7 @@ func runTask(bin, model string, t task) result {
if t.MaxSteps > 0 {
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
}
args = appendBenchmarkProfileArgs(args, profile)
args = append(args, t.Prompt)
cmd := exec.CommandContext(ctx, bin, args...)
@@ -268,7 +294,11 @@ func render(results []result) string {
}
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark\n\n")
profile := benchmarkProfileBaseline
if len(results) > 0 && results[0].Profile != "" {
profile = results[0].Profile
}
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile)
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
passed, ran, pct(passed, ran), pct(hit, hit+miss),
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"strings"
)
const (
benchmarkProfileBaseline = "baseline"
benchmarkProfileDelivery = "delivery"
)
func normalizeBenchmarkProfile(profile string) (string, error) {
switch strings.ToLower(strings.TrimSpace(profile)) {
case "", benchmarkProfileBaseline:
return benchmarkProfileBaseline, nil
case benchmarkProfileDelivery:
return benchmarkProfileDelivery, nil
default:
return "", fmt.Errorf("unknown benchmark profile %q (want baseline or delivery)", profile)
}
}
func appendBenchmarkProfileArgs(args []string, profile string) []string {
if profile == benchmarkProfileDelivery {
return append(args, "--profile", "delivery")
}
return args
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"reflect"
"testing"
)
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
args := []string{"run", "fix the bug"}
if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) {
t.Fatalf("baseline args changed: %v", got)
}
}
func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) {
args := []string{"run"}
got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery)
want := []string{"run", "--profile", "delivery"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("delivery args = %v, want %v", got, want)
}
}
func TestNormalizeBenchmarkProfile(t *testing.T) {
for _, input := range []string{"", "baseline", " BASELINE "} {
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
t.Fatalf("normalize(%q) = %q, %v", input, got, err)
}
}
if got, err := normalizeBenchmarkProfile("delivery"); err != nil || got != benchmarkProfileDelivery {
t.Fatalf("normalize(delivery) = %q, %v", got, err)
}
if _, err := normalizeBenchmarkProfile("fast"); err == nil {
t.Fatal("unknown profile should fail")
}
}
+381
View File
@@ -0,0 +1,381 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
"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"`
PlanID string `json:"planId"`
Preview []repair.RepairPlanPreview `json:"preview"`
PreviewID string `json:"previewId"`
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(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
}
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
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")
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
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)
}
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
expectedPreviewID := strings.TrimSpace(*previewID)
if *yes && expectedPreviewID == "" {
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
return 2
}
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
return 1
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = actualPreviewID
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, repairPlanProviderConfig(entry, 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 repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return 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": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
},
}
}
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"
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
entry := &config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://opencode.ai/zen/go/v1",
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
}
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
if effort := got.Extra["effort"]; effort != "max" {
t.Fatalf("effort = %#v, want max", effort)
}
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
}
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
}
}
// 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 writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
t.Helper()
file := filepath.Join(t.TempDir(), "plan.json")
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
plan, err := repair.DecodeRepairPlan(raw)
if err != nil {
t.Fatal(err)
}
return file, plan
}
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, _ := writeDerivedStatePlan(t)
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
t.Fatal(err)
}
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
t.Fatalf("exit code = %d, want stale preview refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
t.Fatalf("exit code = %d, want confirmed preview application", got)
}
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
}
}
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)
}
}
}
+415
View File
@@ -0,0 +1,415 @@
// 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"
"errors"
"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)
}
forceSafeMode := false
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
return 1
}
forceSafeMode = true
}
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
} else if result.Cleared {
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode || forceSafeMode
if !useSafeMode && 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
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,9 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
+24
View File
@@ -0,0 +1,24 @@
//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
@@ -0,0 +1,36 @@
//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
@@ -0,0 +1,5 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
+27
View File
@@ -0,0 +1,27 @@
//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
}
}
+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)
}
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"flag"
"fmt"
"log"
"reasonix/internal/remote/protocolgen"
)
func main() {
check := flag.Bool("check", false, "compare generated artifacts without writing them")
root := flag.String("root", ".", "repository root containing generated artifacts")
flag.Parse()
log.SetFlags(0)
artifacts, err := protocolgen.Generate()
if err != nil {
log.Fatal(err)
}
if *check {
if err := protocolgen.Check(*root, artifacts); err != nil {
log.Fatal(err)
}
fmt.Println("Remote protocol artifacts are up to date.")
return
}
if err := protocolgen.Write(*root, artifacts); err != nil {
log.Fatal(err)
}
for _, artifact := range artifacts {
fmt.Println(artifact.Path)
}
}
+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)
}
+69
View File
@@ -0,0 +1,69 @@
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-preview.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/**
+48 -30
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.
@@ -172,16 +182,17 @@ minisign -Vm Reasonix-darwin-arm64.zip \
-P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq
```
## Editor seam (Monaco / CodeMirror)
## Editor seams and workspace file previews
Code and diff rendering go through two components with stable prop contracts and a
lazy boundary, so a heavy editor stays out of the initial bundle and dropping one
in is a one-line change — no consumer touches:
Code and diff rendering go through two components with stable prop contracts and
lazy boundaries, so heavier viewers stay out of the initial bundle. `CodeViewer`
keeps the compact highlighted viewer for chat, Markdown, and tool output, while
workspace file previews opt into the searchable line-number viewer:
| Component | Props | Default impl | Upgrade |
|---|---|---|---|
| `components/CodeViewer.tsx` | `EditorProps` | `editors/PlainCode.tsx` (`<pre>`) | swap the lazy import for `editors/MonacoCode` or `editors/CodeMirrorCode` |
| `components/DiffView.tsx` | `DiffProps` | `editors/PlainDiff.tsx` (LCS line diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
| `components/CodeViewer.tsx` | `EditorProps` | `editors/HljsCode.tsx`; `editors/LineNumberCode.tsx` when `showLineNumbers` is enabled | extend the implementation selection for Monaco or CodeMirror |
| `components/DiffView.tsx` | `DiffProps` | `editors/HljsDiff.tsx` (highlighted LCS/unified diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
```sh
# Monaco
@@ -191,14 +202,19 @@ pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
```
Then add `editors/MonacoCode.tsx` (default-export a component taking
`EditorProps`) and point `CodeViewer.tsx`'s `lazy(() => import(...))` at it.
`EditorProps`) and update the implementation selection in `CodeViewer.tsx`.
`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
both seams light up everywhere at once.
Markdown itself is currently minimal (fenced code + plain text). Upgrade path:
`pnpm add react-markdown remark-gfm` and render in `components/Markdown.tsx`,
keeping fenced code delegated to `CodeViewer`.
`WorkspacePanel` passes `showLineNumbers` for text-file previews. The resulting
viewer provides a line-number gutter, viewer-scoped Ctrl/Cmd+F search with case
and whole-word options, copy support, and virtualized rendering above 100 lines.
Search marks are applied only to visible rows so query input does not rebuild the
entire highlighted document. Files above 512 KiB or 20,000 lines keep line
numbers, search, copy, and virtualization but use escaped plain text instead of
syntax highlighting. Workspace files are previewed up to 2 MiB; larger files
display the first 2 MiB with a localized truncation notice.
## Multi-platform adaptation
@@ -223,7 +239,15 @@ handled here, and what to reach for if a target misbehaves:
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
`REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
off.
off. The WebView2 shell always uses a direct connection for embedded assets
and loopback remote-workspace pages; provider and other outbound traffic keeps
using Reasonix's own proxy configuration. Remote Markdown images are fetched
by the Go backend with the same proxy settings and re-served from the local
asset origin, so WebView2 never bypasses the configured proxy for them. Image
hosts must resolve locally to public addresses; direct, HTTP(S)-proxy, and
SOCKS-proxy connections are pinned to those vetted IPs while preserving the
original Host and TLS SNI. If the DOM is still not ready after 15 seconds, the
hidden startup window is shown with a native recovery prompt.
- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
room for the traffic lights.
@@ -258,26 +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.
When Memory v5 is enabled, the same aggregate metrics pipeline may include only
content-free count/size buckets such as injection on/off, compiled-token bucket,
IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
is controlled from Settings > General > "Memory v5" and shares the user/global
`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
counts, lifecycle/window failure buckets, and preference buckets; they never
include conversations, prompts, keys, paths, base URLs, or file contents.
+3806 -736
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -247,6 +247,13 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
}
if got := saved.Tabs[0].SessionPath; got != recoveryPath {
t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
}
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
@@ -357,6 +364,9 @@ func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
@@ -371,6 +381,10 @@ func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
saved := loadTabsFile()
if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
}
deadline := time.After(time.Second)
for {
-1
View File
@@ -735,7 +735,6 @@ func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
}
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new session: %v", err)
}
+2396 -171
View File
File diff suppressed because it is too large. Load diff
+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()
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
)
// TestBuildTabControllerIgnoresRetiredAutoRecoveryKillSwitch exercises the
// desktop wiring after boot.Build. Retired global/project keys must not disable
// Auto Guard either during controller construction or fresh-session binding.
func TestBuildTabControllerIgnoresRetiredAutoRecoveryKillSwitch(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
t.Fatalf("mkdir user config: %v", err)
}
if err := os.WriteFile(userCfg, []byte(`
default_model = "test-model"
[agent]
auto_recovery_checkpoint = "on"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`), 0o644); err != nil {
t.Fatalf("write user config: %v", err)
}
root := robustTempDir(t)
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
default_model = "test-model"
[agent]
auto_recovery_checkpoint = "off"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "project-auto-guard-off",
Scope: "project",
WorkspaceRoot: root,
model: "test-model",
tokenMode: boot.TokenModeFull,
mode: "normal",
toolApprovalMode: control.ToolApprovalAuto,
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
defer func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
app.releaseTabSharedHost(tab)
tab.releaseSessionLease()
}()
app.buildTabController(tab)
if tab.StartupErr != "" {
t.Fatalf("build tab controller: %s", tab.StartupErr)
}
ctrl, ok := tab.Ctrl.(*control.Controller)
if !ok || ctrl == nil {
t.Fatalf("tab controller = %T, want *control.Controller", tab.Ctrl)
}
fresh := filepath.Join(ctrl.SessionDir(), "fresh-auto-guard-off.jsonl")
ctrl.SetFreshSessionPath(fresh)
if got := ctrl.SessionPath(); got != fresh {
t.Fatalf("fresh session path = %q, want %q", got, fresh)
}
}
+3 -2
View File
@@ -29,14 +29,15 @@ func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
}
// Window expired: warns again.
aged := time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
tab.lastAutosaveWarnAt = aged
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(first) {
if !third.After(aged) {
t.Fatal("expired window did not re-arm the autosave warning")
}
+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))}
+4 -5
View File
@@ -112,23 +112,22 @@ func (a *App) bridgeAnnounce(tabID, text string) {
// bridgeDrive 把远程文本提交为可见 tab 的新 turn,并为这一轮挂上事件转发器,
// 让输出流回接管聊天(转发器在 TurnDone 自动卸载)。
func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
tab, ctrl, err := a.beginTabTurn(tabID, false)
admission, ctrl, err := a.beginTabTurn(tabID, false)
if err != nil {
if err == control.ErrTurnRunning {
return errDriveBusy
}
return err
}
defer admission.abort()
tab := admission.tab
if tab.sink == nil {
a.finishTabTurnStart(tab, nil)
return fmt.Errorf("会话事件通道不可用,无法驱动")
}
// A local submission may have reclaimed the tab while this drive was waiting
// for the per-tab admission gate. Revalidate ownership only after the gate is
// held, immediately before attaching the route-specific forwarder.
if a.botBridge == nil || a.botBridge.TakeoverTab(route) != tabID {
tab.sink.cancelTurnStart()
tab.turnStartMu.Unlock()
return fmt.Errorf("接管已解除,请重新接管会话")
}
target := botForwardTarget{
@@ -143,7 +142,7 @@ func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error
// Confirm the submit actually started a turn. If nothing is running now, the
// controller was rotating and the submit no-oped — detach this exact
// generation so a later turn's output does not leak.
if !a.finishTabTurnStart(tab, ctrl) {
if !admission.finish(ctrl) {
tab.sink.clearBotSink(generation)
return errDriveBusy
}
+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)
+58
View File
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
@@ -27,6 +28,13 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
{"SearchFileRefsForTab", app.SearchFileRefsForTab("missing", "file")},
{"ListTabs", app.ListTabs()},
{"ListProjectTree", app.ListProjectTree()},
{"AvailableSubagentTools", app.AvailableSubagentTools()},
{"AutoResearchList", app.AutoResearchList("missing")},
{"AutoResearchFindings", app.AutoResearchFindings("missing", 10)},
{"MCPServers", app.MCPServers()},
{"Plugins", app.Plugins()},
{"HeartbeatListTasks", app.HeartbeatListTasks()},
{"HeartbeatReloadTasks", app.HeartbeatReloadTasks()},
}
for _, tc := range cases {
assertNonNilSliceJSON(t, tc.name, tc.got)
@@ -56,6 +64,22 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("DesktopStartupSettings() contains nil array fields: %+v", got)
}
boundPayloads := []struct {
name string
got any
}{
{"CapabilityDiagnostics", app.CapabilityDiagnostics(false)},
{"Capabilities", app.Capabilities()},
{"SkillsSettings", app.SkillsSettings()},
{"HistoryPage", app.HistoryPage(0, 20)},
{"Effort", app.Effort()},
{"Memory", app.Memory()},
{"MemorySuggestions", app.MemorySuggestions()},
}
for _, tc := range boundPayloads {
assertRequiredJSONSlicesNonNil(t, tc.name, reflect.ValueOf(tc.got))
}
}
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
@@ -75,3 +99,37 @@ func assertNonNilSliceJSON(t *testing.T, name string, got any) {
t.Fatalf("%s JSON encoded as null; frontend expects []", name)
}
}
func assertRequiredJSONSlicesNonNil(t *testing.T, path string, value reflect.Value) {
t.Helper()
for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
if value.IsNil() {
return
}
value = value.Elem()
}
switch value.Kind() {
case reflect.Slice, reflect.Array:
if value.Kind() == reflect.Slice && value.IsNil() {
t.Fatalf("%s is a nil slice; JSON contract requires []", path)
}
for i := 0; i < value.Len(); i++ {
assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
}
case reflect.Struct:
typ := value.Type()
for i := 0; i < value.NumField(); i++ {
fieldType := typ.Field(i)
if fieldType.PkgPath != "" {
continue
}
jsonTag := fieldType.Tag.Get("json")
if jsonTag == "-" || strings.Contains(jsonTag, ",omitempty") {
continue
}
fieldPath := path + "." + fieldType.Name
assertRequiredJSONSlicesNonNil(t, fieldPath, value.Field(i))
}
}
}
+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>
+25 -6
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,14 +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
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-desktop
Exec=reasonix-guard launch --detach
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
+168 -13
View File
@@ -8,8 +8,8 @@ Unicode true
## Wails' default template:
##
## 1. REQUEST_EXECUTION_LEVEL "user" + InstallDir under $LOCALAPPDATA - install
## without administrator rights. This is what lets the auto-updater re-run a
## freshly downloaded installer silently (`/S`) with no UAC prompt.
## without administrator rights. This lets the auto-updater re-run a freshly
## downloaded installer in a visible progress-only mode with no UAC prompt.
## 2. Uninstall registry under HKCU (not HKLM). Wails' wails.writeUninstaller /
## wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
## cannot write - so we inline HKCU versions below instead.
@@ -18,9 +18,9 @@ Unicode true
## a build that did not write InstallLocation yet, .onInit falls back to the
## old DisplayIcon path before using the default. Without this, every release
## forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if they had
## moved the install to a different drive (e.g. D:\Tools\Reasonix); the silent
## auto-updater would re-run with /S into the wrong dir, leaving the old
## install orphaned.
## moved the install to a different drive (e.g. D:\Tools\Reasonix); the
## auto-updater would overwrite the wrong dir, leaving the old install
## orphaned.
##
## Everything else mirrors Wails' generated default. Defines below override the
## ProjectInfo values that wails_tools.nsh would otherwise populate.
@@ -60,25 +60,51 @@ ManifestDPIAware true
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipSetupPageForUpdate
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipSetupPageForUpdate
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!define MUI_PAGE_CUSTOMFUNCTION_SHOW reasonix.showUpdateProgress
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipFinishPageForUpdate
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
!insertmacro MUI_LANGUAGE "English"
!insertmacro MUI_LANGUAGE "SimpChinese"
!insertmacro MUI_LANGUAGE "TradChinese"
## 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"'
LangString reasonixUpdateTitle ${LANG_ENGLISH} "Updating Reasonix"
LangString reasonixUpdateTitle ${LANG_SIMPCHINESE} "正在更新 Reasonix"
LangString reasonixUpdateTitle ${LANG_TRADCHINESE} "正在更新 Reasonix"
LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified update. Reasonix will restart automatically."
LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"
LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"
## 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}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_GUARD "reasonix-guard.exe"
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
!define REASONIX_CLI "reasonix-cli.exe"
!define REASONIX_PORTABLE_ENTRY "Reasonix.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.
@@ -88,7 +114,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}"
@@ -100,8 +130,8 @@ ShowInstDetails show # This will always show the installation details.
# via InstallDirRegKey above. Without this, every release would force the
# user back to %LOCALAPPDATA%\Programs\Reasonix even if they had moved
# the install to a different drive (e.g. D:\Tools\Reasonix). The auto-
# updater re-runs this installer with /S and trusts the persisted path,
# so it has to be present before the silent re-install.
# updater trusts this persisted path, so it has to be present before the
# visible progress-only re-install.
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
@@ -117,6 +147,28 @@ ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
; The helper passes /REASONIXUPDATE=1 and a final /D=<current directory>.
; This mode remains visible but skips every page that could change the
; 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
IfErrors reasonix_update_mode_done
StrCmp $R1 "1" 0 reasonix_update_mode_done
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
; as a compatibility bridge before falling back to the per-user default.
@@ -133,15 +185,67 @@ fallback:
done:
FunctionEnd
Function reasonix.skipSetupPageForUpdate
StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_setup_page
Abort
reasonix_show_setup_page:
FunctionEnd
Function reasonix.showUpdateProgress
StrCmp $ReasonixUpdateMode "1" 0 reasonix_update_progress_done
!insertmacro MUI_HEADER_TEXT "$(reasonixUpdateTitle)" "$(reasonixUpdateSubtitle)"
SetDetailsView hide
SetAutoClose true
BringToFront
reasonix_update_progress_done:
FunctionEnd
Function reasonix.skipFinishPageForUpdate
StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_finish_page
Abort
reasonix_show_finish_page:
FunctionEnd
Function reasonix.waitForExecutableUnlock
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
StrCpy $0 0
retry:
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_guard
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
check_guard:
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_GUARD}" a
IfErrors locked
FileClose $1
check_launcher:
IfFileExists "$INSTDIR\${REASONIX_LAUNCHER}" 0 check_cli
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
IfErrors locked
FileClose $1
check_cli:
IfFileExists "$INSTDIR\${REASONIX_CLI}" 0 check_portable_entry
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a
IfErrors locked
FileClose $1
check_portable_entry:
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 done
ClearErrors
FileOpen $1 "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" a
IfErrors locked
FileClose $1
Goto done
locked:
@@ -169,26 +273,73 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; Current auto-update helpers extract the signed installer payload into a
; unique temporary directory, then compare-and-publish the exact claimed
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
; in-place update behavior required to upgrade into the hardened release.
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
reasonix_copy_payload:
SetOutPath $INSTDIR
; The release workflow embeds a minisign-authenticated manifest after
; SignPath has signed the five executable members. Only the extraction
; handoff needs these files; a normal installation does not persist them.
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
!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
reasonix_payload_metadata_done:
!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 fall back to installer-side waiting only."
!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}"
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
; 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
reasonix_launcher_payload_done:
!else
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
reasonix_launcher_payload_done_no_launcher:
!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
StrCmp $ReasonixStageMode "1" reasonix_section_done
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
reasonix_section_done:
SectionEnd
Section "uninstall"
@@ -199,6 +350,10 @@ Section "uninstall"
; Precision uninstall: delete main application files
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 "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"os"
"strings"
"testing"
)
func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
data, err := os.ReadFile("../scripts/desktop-build.sh")
if err != nil {
t.Fatal(err)
}
script := string(data)
for _, want := range []string{
`SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`,
`SOURCE_REVISION="$SOURCE_REVISION+dirty"`,
`source_revision_ldflag="-X reasonix/internal/remote/protocol.linkedSourceRevision=$SOURCE_REVISION"`,
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag"`,
`-X main.version=$VERSION $source_revision_ldflag`,
} {
if !strings.Contains(script, want) {
t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want)
}
}
revisionIndex := strings.Index(script, `SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`)
packagingMutationIndex := strings.Index(script, `node -e 'const fs=require("fs")`)
if revisionIndex < 0 || packagingMutationIndex < 0 || revisionIndex >= packagingMutationIndex {
t.Fatal("desktop-build.sh must capture the source revision before mutating packaging metadata")
}
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"reasonix/internal/capdiag"
"reasonix/internal/plugin"
)
// CapabilityDiagnostics returns a read-only capability report for the active
// workspace. When includeSessionRuntime is true, connected/failed/deferred
// status is merged from the active tab Host only — no new MCP processes are
// started, and no controller rebuild or session snapshot is triggered.
func (a *App) CapabilityDiagnostics(includeSessionRuntime bool) capdiag.Report {
root, host := a.activeDiagSnapshot(includeSessionRuntime)
opts := capdiag.Options{Root: root, Live: false}
if !includeSessionRuntime {
return capdiag.Collect(opts)
}
opts.RuntimeHost = host
if host == nil {
return capdiag.CollectWithRuntimeUnavailable(opts)
}
return capdiag.Collect(opts)
}
// activeDiagSnapshot reads workspace root and optional Host under one lock so
// a tab switch cannot pair project A's config with project B's runtime Host.
func (a *App) activeDiagSnapshot(includeHost bool) (root string, host *plugin.Host) {
a.mu.RLock()
defer a.mu.RUnlock()
tab := a.activeTabLocked()
if tab == nil {
return "", nil
}
root = tab.WorkspaceRoot
if includeHost && tab.Ctrl != nil {
host = tab.Ctrl.Host()
}
return root, host
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/control"
"reasonix/internal/plugin"
)
func TestCapabilityDiagnosticsStaticUsesWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# workspace agents\n"), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.mu.Lock()
tab := &WorkspaceTab{
ID: "diag",
Scope: "project",
WorkspaceRoot: root,
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{"diag": tab}
app.tabOrder = []string{"diag"}
app.activeTabID = "diag"
app.mu.Unlock()
report := app.CapabilityDiagnostics(false)
if report.Live {
t.Fatal("static diagnostics must not set live")
}
if report.SchemaVersion != 1 {
t.Fatalf("schema = %d", report.SchemaVersion)
}
}
func TestCapabilityDiagnosticsRuntimeUsesActiveHost(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
host := plugin.NewHost()
ctrl := control.New(control.Options{Host: host})
app.setTestCtrl(ctrl, "test-model")
report := app.CapabilityDiagnostics(true)
if report.SchemaVersion != 1 {
t.Fatalf("schema = %d", report.SchemaVersion)
}
_ = app.CapabilityDiagnostics(true)
if ctrl.Host() != host {
t.Fatal("diagnostics must reuse the same Host pointer")
}
}
func TestCapabilityDiagnosticsAtomicSnapshot(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
host := plugin.NewHost()
ctrl := control.New(control.Options{Host: host})
root := t.TempDir()
app.mu.Lock()
app.tabs = map[string]*WorkspaceTab{
"t1": {
ID: "t1", Scope: "project", WorkspaceRoot: root, Ready: true,
Ctrl: ctrl, disabledMCP: map[string]ServerView{},
},
}
app.tabOrder = []string{"t1"}
app.activeTabID = "t1"
app.mu.Unlock()
gotRoot, gotHost := app.activeDiagSnapshot(true)
if gotRoot != root {
t.Fatalf("root = %q, want %q", gotRoot, root)
}
if gotHost != host {
t.Fatal("host mismatch from atomic snapshot")
}
}
func TestCapabilityDiagnosticsRuntimeUnavailableWithoutTab(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
// Explicitly empty tabs.
app.mu.Lock()
app.tabs = map[string]*WorkspaceTab{}
app.tabOrder = nil
app.activeTabID = ""
app.mu.Unlock()
report := app.CapabilityDiagnostics(true)
found := false
for _, is := range report.Issues {
if is.Code == "mcp.runtime_unavailable" {
found = true
break
}
}
if !found {
t.Fatalf("expected mcp.runtime_unavailable, issues=%+v", report.Issues)
}
}
+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/#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
+111 -3
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",
@@ -85,9 +86,12 @@ func TestGenManifest(t *testing.T) {
if m.Version != "v1.2.0" {
t.Fatalf("version = %q, want v1.2.0", m.Version)
}
if m.DownloadPage != "https://reasonix.io/#start" {
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)
}
}
}
+5 -1
View File
@@ -3,7 +3,11 @@ package main
import "fmt"
func installerCommandLine(installer, dir string) string {
line := fmt.Sprintf(`"%s" /S`, installer)
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
// 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
}
+8 -2
View File
@@ -5,13 +5,19 @@ import (
"testing"
)
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /S /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)
}
if strings.Contains(got, " /S") {
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
}
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)
}
+359 -6
View File
@@ -3,33 +3,56 @@
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/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 string
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID 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")
if err := fs.Parse(args); err != nil {
return 2
}
@@ -38,18 +61,129 @@ 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 createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if 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 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,
filepath.Join(installDir, "reasonix-desktop.exe"),
windowsReleaseUnitPaths(installDir),
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return 1
}
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(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
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(relaunch, 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(relaunch, installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -57,6 +191,179 @@ func run(args []string) int {
return 0
}
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) {
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
if err != nil {
return false, nil, err
}
// Persist recovery intent before the first live rename. If the helper exits
// between release-unit members, Guard rolls the exact pending transaction
// back instead of launching a mixed install.
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
}
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
if err != nil {
return true, receipts, err
}
return true, receipts, 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 {
@@ -96,10 +403,56 @@ func waitForProcessExit(pid uint32, timeout time.Duration) error {
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
// Keep the helper itself hidden, but let the NSIS update-progress window be
// 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
@@ -0,0 +1,428 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/repair"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
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`,
"--installer-sha256", testInstallerSHA256,
}); code != 2 {
t.Fatalf("run without --to-version = %d, want 2", code)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
installDir := t.TempDir()
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 &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, nil, 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", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
"v2",
"2026-07-29T00:00:00Z",
"transaction-1",
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 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 := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
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
}
const createdAt = "2026-07-29T00:00:00Z"
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", 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 := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
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, nil, 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 := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
CreatedAt: "2026-07-29T00:00:00Z",
TargetKind: "file",
}
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 })
}
+232
View File
@@ -0,0 +1,232 @@
// Command windows-resource stamps Reasonix branding and metadata into a Windows
// support executable after it has been built. Wails already uses the same
// winres library for the desktop executable; keeping the support binaries on the
// same resource path avoids generic Explorer, shortcut, and taskbar icons.
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/tc-hib/winres"
"github.com/tc-hib/winres/version"
)
const (
companyName = "Reasonix"
copyright = "Copyright © 2026 Reasonix Contributors"
)
type resourceOptions struct {
executable string
icon string
numericVersion string
fileDescription string
internalName string
originalName string
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "windows-resource:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("windows-resource", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var opts resourceOptions
fs.StringVar(&opts.executable, "exe", "", "Windows executable to stamp")
fs.StringVar(&opts.icon, "icon", "", "ICO file to embed")
fs.StringVar(&opts.numericVersion, "version", "", "numeric X.Y.Z version")
fs.StringVar(&opts.fileDescription, "description", "", "Windows FileDescription")
fs.StringVar(&opts.internalName, "internal-name", "", "Windows InternalName")
fs.StringVar(&opts.originalName, "original-filename", "", "Windows OriginalFilename")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
}
for name, value := range map[string]string{
"-exe": opts.executable,
"-icon": opts.icon,
"-version": opts.numericVersion,
"-description": opts.fileDescription,
"-internal-name": opts.internalName,
"-original-filename": opts.originalName,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
}
return stampExecutable(opts)
}
func stampExecutable(opts resourceOptions) error {
numericVersion, err := parseNumericVersion(opts.numericVersion)
if err != nil {
return err
}
iconFile, err := os.Open(opts.icon)
if err != nil {
return fmt.Errorf("open icon: %w", err)
}
ico, err := winres.LoadICO(iconFile)
closeErr := iconFile.Close()
if err != nil {
return fmt.Errorf("load icon: %w", err)
}
if closeErr != nil {
return fmt.Errorf("close icon: %w", closeErr)
}
rs := winres.ResourceSet{}
if err := rs.SetIcon(winres.ID(1), ico); err != nil {
return fmt.Errorf("set icon: %w", err)
}
rs.SetManifest(winres.AppManifest{
Identity: winres.AssemblyIdentity{
Name: "Reasonix." + opts.internalName,
Version: numericVersion,
},
Description: opts.fileDescription,
ExecutionLevel: winres.AsInvoker,
DPIAwareness: winres.DPIPerMonitorV2,
LongPathAware: true,
UseCommonControlsV6: true,
})
info := version.Info{
FileVersion: numericVersion,
ProductVersion: numericVersion,
}
for key, value := range map[string]string{
version.CompanyName: companyName,
version.FileDescription: opts.fileDescription,
version.FileVersion: opts.numericVersion,
version.InternalName: opts.internalName,
version.LegalCopyright: copyright,
version.OriginalFilename: opts.originalName,
version.ProductName: "Reasonix",
version.ProductVersion: opts.numericVersion,
version.Comments: "Reasonix desktop support component.",
} {
if err := info.Set(version.LangDefault, key, value); err != nil {
return fmt.Errorf("set version field %s: %w", key, err)
}
}
rs.SetVersionInfo(info)
source, err := os.ReadFile(opts.executable)
if err != nil {
return fmt.Errorf("read executable: %w", err)
}
var stamped bytes.Buffer
if err := rs.WriteToEXE(&stamped, bytes.NewReader(source)); err != nil {
return fmt.Errorf("write resources: %w", err)
}
if err := verifyResources(stamped.Bytes(), opts, numericVersion); err != nil {
return fmt.Errorf("verify resources: %w", err)
}
mode := os.FileMode(0o755)
if stat, err := os.Stat(opts.executable); err == nil {
mode = stat.Mode()
}
if err := replaceFile(opts.executable, stamped.Bytes(), mode); err != nil {
return fmt.Errorf("replace executable: %w", err)
}
return nil
}
func parseNumericVersion(value string) ([4]uint16, error) {
parts := strings.Split(strings.TrimSpace(value), ".")
if len(parts) != 3 && len(parts) != 4 {
return [4]uint16{}, fmt.Errorf("version %q must have three or four numeric parts", value)
}
var parsed [4]uint16
for i, part := range parts {
if part == "" {
return [4]uint16{}, fmt.Errorf("version %q contains an empty part", value)
}
n, err := strconv.ParseUint(part, 10, 16)
if err != nil {
return [4]uint16{}, fmt.Errorf("version %q is not a Windows numeric version: %w", value, err)
}
parsed[i] = uint16(n)
}
return parsed, nil
}
func verifyResources(executable []byte, opts resourceOptions, numericVersion [4]uint16) error {
rs, err := winres.LoadFromEXE(bytes.NewReader(executable))
if err != nil {
return err
}
if _, err := rs.GetIcon(winres.ID(1)); err != nil {
return fmt.Errorf("application icon: %w", err)
}
manifest := rs.Get(winres.RT_MANIFEST, winres.ID(1), winres.LCIDDefault)
if len(manifest) == 0 || !bytes.Contains(manifest, []byte(`requestedExecutionLevel level="asInvoker"`)) {
return errors.New("asInvoker application manifest is missing")
}
versionData := rs.Get(winres.RT_VERSION, winres.ID(1), version.LangDefault)
info, err := version.FromBytes(versionData)
if err != nil {
return fmt.Errorf("version info: %w", err)
}
if info.FileVersion != numericVersion || info.ProductVersion != numericVersion {
return fmt.Errorf("numeric version mismatch: file=%v product=%v want=%v", info.FileVersion, info.ProductVersion, numericVersion)
}
table := info.Table()[version.LangDefault]
if table == nil {
return errors.New("English version string table is missing")
}
for key, want := range map[string]string{
version.CompanyName: companyName,
version.FileDescription: opts.fileDescription,
version.InternalName: opts.internalName,
version.OriginalFilename: opts.originalName,
version.ProductName: "Reasonix",
} {
if got := (*table)[key]; got != want {
return fmt.Errorf("version field %s=%q, want %q", key, got, want)
}
}
return nil
}
func replaceFile(path string, data []byte, mode os.FileMode) error {
temp, err := os.CreateTemp(filepath.Dir(path), ".reasonix-resource-*.exe")
if err != nil {
return err
}
tempName := temp.Name()
defer os.Remove(tempName)
if err := temp.Chmod(mode); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(data); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempName, path)
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"debug/pe"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/tc-hib/winres"
"github.com/tc-hib/winres/version"
)
func TestParseNumericVersion(t *testing.T) {
for _, test := range []struct {
value string
want [4]uint16
}{
{"1.17.13", [4]uint16{1, 17, 13, 0}},
{"2.3.4.5", [4]uint16{2, 3, 4, 5}},
} {
got, err := parseNumericVersion(test.value)
if err != nil {
t.Fatalf("parseNumericVersion(%q): %v", test.value, err)
}
if got != test.want {
t.Fatalf("parseNumericVersion(%q) = %v, want %v", test.value, got, test.want)
}
}
for _, value := range []string{"", "1.2", "1.2.3-beta", "1.2.70000"} {
if _, err := parseNumericVersion(value); err == nil {
t.Errorf("parseNumericVersion(%q) unexpectedly succeeded", value)
}
}
}
func TestStampExecutableSupportsWindowsReleaseArchitectures(t *testing.T) {
icon, err := filepath.Abs(filepath.Join("..", "..", "build", "windows", "icon.ico"))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
arch string
machine uint16
}{
{"amd64", pe.IMAGE_FILE_MACHINE_AMD64},
{"arm64", pe.IMAGE_FILE_MACHINE_ARM64},
} {
t.Run(test.arch, func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "main.go")
if err := os.WriteFile(source, []byte("package main\nfunc main() {}\n"), 0o600); err != nil {
t.Fatal(err)
}
executable := filepath.Join(dir, "reasonix-launcher.exe")
cmd := exec.Command("go", "build", "-trimpath", "-o", executable, source)
cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH="+test.arch, "CGO_ENABLED=0")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("cross-build %s fixture: %v\n%s", test.arch, err, output)
}
opts := resourceOptions{
executable: executable,
icon: icon,
numericVersion: "1.17.13",
fileDescription: "Reasonix Launcher",
internalName: "reasonix-launcher",
originalName: "reasonix-launcher.exe",
}
if err := stampExecutable(opts); err != nil {
t.Fatal(err)
}
peFile, err := pe.Open(executable)
if err != nil {
t.Fatal(err)
}
if peFile.Machine != test.machine {
t.Errorf("PE machine = %#x, want %#x", peFile.Machine, test.machine)
}
peFile.Close()
data, err := os.ReadFile(executable)
if err != nil {
t.Fatal(err)
}
rs, err := winres.LoadFromEXE(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
if _, err := rs.GetIcon(winres.ID(1)); err != nil {
t.Fatalf("embedded icon: %v", err)
}
info, err := version.FromBytes(rs.Get(winres.RT_VERSION, winres.ID(1), version.LangDefault))
if err != nil {
t.Fatal(err)
}
if got := (*info.Table()[version.LangDefault])[version.OriginalFilename]; got != "reasonix-launcher.exe" {
t.Errorf("OriginalFilename = %q", got)
}
manifest := rs.Get(winres.RT_MANIFEST, winres.ID(1), winres.LCIDDefault)
if !bytes.Contains(manifest, []byte(`dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,system`)) {
t.Errorf("per-monitor-v2 manifest setting is missing: %s", manifest)
}
})
}
}
func TestReplaceFilePreservesMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose Unix executable mode bits")
}
path := filepath.Join(t.TempDir(), "program.exe")
if err := os.WriteFile(path, []byte("old"), 0o751); err != nil {
t.Fatal(err)
}
if err := replaceFile(path, []byte("new"), 0o751); err != nil {
t.Fatal(err)
}
stat, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := stat.Mode().Perm(); got != 0o751 {
t.Fatalf("mode = %#o, want 0751", got)
}
}
+42 -38
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"
@@ -59,45 +60,47 @@ type crashBreadcrumb struct {
}
type crashReport struct {
Kind string `json:"kind"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Message string `json:"message"`
Device deviceInfo `json:"device"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Source string `json:"source,omitempty"`
Label string `json:"label,omitempty"`
ErrorType string `json:"errorType,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Stack string `json:"stack,omitempty"`
ComponentStack string `json:"componentStack,omitempty"`
TopFrame string `json:"topFrame,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
Channel string `json:"channel,omitempty"`
Language string `json:"language,omitempty"`
View string `json:"view,omitempty"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"`
OccurredAt string `json:"occurredAt,omitempty"`
Kind string `json:"kind"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Message string `json:"message"`
Device deviceInfo `json:"device"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Source string `json:"source,omitempty"`
Label string `json:"label,omitempty"`
ErrorType string `json:"errorType,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Stack string `json:"stack,omitempty"`
ComponentStack string `json:"componentStack,omitempty"`
TopFrame string `json:"topFrame,omitempty"`
FingerprintHint string `json:"fingerprintHint,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
Channel string `json:"channel,omitempty"`
Language string `json:"language,omitempty"`
View string `json:"view,omitempty"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"`
OccurredAt string `json:"occurredAt,omitempty"`
}
type frontendCrashPayload struct {
SchemaVersion int `json:"schemaVersion"`
Kind string `json:"kind"`
Source string `json:"source"`
Label string `json:"label"`
Message string `json:"message"`
ErrorType string `json:"errorType"`
ErrorMessage string `json:"errorMessage"`
Stack string `json:"stack"`
ComponentStack string `json:"componentStack"`
TopFrame string `json:"topFrame"`
BuildCommit string `json:"buildCommit"`
Channel string `json:"channel"`
Language string `json:"language"`
View string `json:"view"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"`
OccurredAt string `json:"occurredAt"`
SchemaVersion int `json:"schemaVersion"`
Kind string `json:"kind"`
Source string `json:"source"`
Label string `json:"label"`
Message string `json:"message"`
ErrorType string `json:"errorType"`
ErrorMessage string `json:"errorMessage"`
Stack string `json:"stack"`
ComponentStack string `json:"componentStack"`
TopFrame string `json:"topFrame"`
FingerprintHint string `json:"fingerprintHint"`
BuildCommit string `json:"buildCommit"`
Channel string `json:"channel"`
Language string `json:"language"`
View string `json:"view"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"`
OccurredAt string `json:"occurredAt"`
}
func normalizeReportKind(kind string) (string, bool) {
@@ -213,6 +216,7 @@ func crashReportFromDetail(kind, detail string) (crashReport, error) {
r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes)
r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes)
r.TopFrame = sanitizeCrashText(payload.TopFrame, 300)
r.FingerprintHint = sanitizeCrashText(payload.FingerprintHint, 300)
r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64)
r.Channel = sanitizeCrashField(payload.Channel, 32)
r.Language = sanitizeCrashField(payload.Language, 64)
+16 -15
View File
@@ -100,20 +100,21 @@ func TestCrashReportFromStructuredDetail(t *testing.T) {
secretHex := "abcdefabcdefabcdefabcdefabcdef12"
buildCommit := "0123456789abcdef0123456789abcdef01234567"
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "exception",
Source: "frontend",
Label: "unhandledrejection",
Message: "[unhandledrejection]\n\ninvalid argument at C:\\Users\\alice\\app.ts:1 from [email protected]",
ErrorType: "TypeError",
ErrorMessage: "invalid argument at /Users/alice/project/app.ts api_key=" + apiKey,
Stack: "TypeError: invalid argument\n at run (/Users/alice/project/app.ts:12:3)\nsecret=" + secretHex,
TopFrame: "at run (/Users/alice/project/app.ts:12:3)",
BuildCommit: buildCommit,
Channel: "canary",
Language: "zh-CN",
View: "wails://wails.localhost/index.html?token=" + secretHex,
Breadcrumbs: []crashBreadcrumb{{T: 1, Cat: "bridge", Msg: "turn SubmitToTab token=" + apiKey}},
SchemaVersion: 2,
Kind: "exception",
Source: "frontend",
Label: "unhandledrejection",
Message: "[unhandledrejection]\n\ninvalid argument at C:\\Users\\alice\\app.ts:1 from [email protected]",
ErrorType: "TypeError",
ErrorMessage: "invalid argument at /Users/alice/project/app.ts api_key=" + apiKey,
Stack: "TypeError: invalid argument\n at run (/Users/alice/project/app.ts:12:3)\nsecret=" + secretHex,
TopFrame: "at run (/Users/alice/project/app.ts:12:3)",
FingerprintHint: "build:01234567|view:app://reasonix/|cats:startup>tabs",
BuildCommit: buildCommit,
Channel: "canary",
Language: "zh-CN",
View: "wails://wails.localhost/index.html?token=" + secretHex,
Breadcrumbs: []crashBreadcrumb{{T: 1, Cat: "bridge", Msg: "turn SubmitToTab token=" + apiKey}},
}
detail, err := json.Marshal(payload)
if err != nil {
@@ -129,7 +130,7 @@ func TestCrashReportFromStructuredDetail(t *testing.T) {
if strings.Contains(r.Message, "alice") || strings.Contains(r.ErrorMessage, "alice") || strings.Contains(r.Stack, "alice") {
t.Fatalf("user path was not scrubbed: %+v", r)
}
if r.TopFrame == "" || r.BuildCommit != buildCommit || r.Channel != "canary" || len(r.Breadcrumbs) != 1 {
if r.TopFrame == "" || r.FingerprintHint != payload.FingerprintHint || r.BuildCommit != buildCommit || r.Channel != "canary" || len(r.Breadcrumbs) != 1 {
t.Fatalf("metadata missing: %+v", r)
}
freeText := strings.Join([]string{
+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()
}
+95 -21
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,26 +146,42 @@ func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
path := pendingCrashPath()
body, err := readFileUTF8(path)
if err != nil {
// 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
}
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())
}
+44 -19
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"
@@ -189,3 +197,20 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
t.Error("dev build must leave the pending file untouched")
}
}
func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
t.Setenv("REASONIX_SAFE_MODE", "1")
oldVersion := version
t.Cleanup(func() {
version = oldVersion
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")
}
}
+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)
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/worktree"
)
var (
inspectDeliveryWorktree = worktree.Inspect
createDeliveryWorktree = worktree.Create
)
// DeliveryWorktreeOpenResult is returned after an isolated Git workspace has
// been created and opened as a normal Reasonix project.
type DeliveryWorktreeOpenResult struct {
WorkspaceRoot string `json:"workspaceRoot"`
WorktreeRoot string `json:"worktreeRoot"`
SourceRoot string `json:"sourceRoot"`
Branch string `json:"branch"`
SourceDirty bool `json:"sourceDirty"`
Tab TabMeta `json:"tab"`
}
// DeliveryWorktreeAvailability reports whether workspaceRoot can use the
// optional Git isolation path. A false result never disables Delivery itself;
// the cross-platform workspace writer lease remains the no-Git fallback.
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
}
// CreateDeliveryWorktree creates a durable branch-backed worktree and opens it
// as a project. It never switches or modifies the source checkout, and it does
// not delete the new worktree if later UI registration fails.
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
workspaceRoot = strings.TrimSpace(workspaceRoot)
created, err := createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
if err != nil {
return DeliveryWorktreeOpenResult{}, err
}
var tab TabMeta
if a.singleSurfaceLayoutEnabled() {
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot, boot.TokenModeDelivery)
} else {
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot, boot.TokenModeDelivery)
}
if err != nil {
return DeliveryWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
}
return DeliveryWorktreeOpenResult{
WorkspaceRoot: created.WorkspaceRoot,
WorktreeRoot: created.WorktreeRoot,
SourceRoot: created.SourceRoot,
Branch: created.Branch,
SourceDirty: created.SourceDirty,
Tab: tab,
}, nil
}
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
"reasonix/internal/worktree"
)
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
original := inspectDeliveryWorktree
t.Cleanup(func() { inspectDeliveryWorktree = original })
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
}
got := NewApp().DeliveryWorktreeAvailability("project")
if got.Available || got.Reason != "Git is not installed" || got.RepoRoot != "project" {
t.Fatalf("availability = %+v", got)
}
}
func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", home)
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
managed := config.DeliveryWorktreeDir()
isolatedRoot := filepath.Join(managed, "repo", "id", "project")
if err := os.MkdirAll(isolatedRoot, 0o755); err != nil {
t.Fatal(err)
}
original := createDeliveryWorktree
t.Cleanup(func() { createDeliveryWorktree = original })
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
if source != "source-project" {
t.Fatalf("source = %q", source)
}
if gotManaged != managed {
t.Fatalf("managed root = %q, want %q", gotManaged, managed)
}
return worktree.Result{
WorkspaceRoot: isolatedRoot,
WorktreeRoot: filepath.Dir(isolatedRoot),
SourceRoot: "source-project",
Branch: "reasonix/delivery-test",
SourceDirty: true,
}, nil
}
app := NewApp()
t.Cleanup(func() { app.shutdown(context.Background()) })
result, err := app.CreateDeliveryWorktree("source-project")
if err != nil {
t.Fatal(err)
}
if result.WorkspaceRoot != isolatedRoot || result.Branch != "reasonix/delivery-test" || !result.SourceDirty {
t.Fatalf("result = %+v", result)
}
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active || result.Tab.TokenMode != "delivery" {
t.Fatalf("opened tab = %+v", result.Tab)
}
}
+31 -1
View File
@@ -1,7 +1,37 @@
package main
import fileencoding "reasonix/internal/fileutil/encoding"
import (
"fmt"
"io"
"os"
fileencoding "reasonix/internal/fileutil/encoding"
)
func readFileUTF8(path string) ([]byte, error) {
return fileencoding.ReadFileUTF8(path)
}
func readFileUTF8Limit(path string, limit int64) ([]byte, bool, error) {
if limit < 0 {
return nil, false, fmt.Errorf("invalid read limit %d", limit)
}
f, err := os.Open(path)
if err != nil {
return nil, false, err
}
defer f.Close()
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, false, err
}
if int64(len(raw)) > limit {
return nil, true, nil
}
decoded := fileencoding.DecodeToUTF8(raw)
if int64(len(decoded)) > limit {
return nil, true, nil
}
return decoded, false, nil
}
+348
View File
@@ -0,0 +1,348 @@
package main
import (
"bytes"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestSaveExportFileWritesTextAndBinaryPayloads(t *testing.T) {
t.Parallel()
app := &App{}
dir := t.TempDir()
textPath := filepath.Join(dir, "session.md")
if err := app.SaveExportFile(textPath, "# 会话\n", false); err != nil {
t.Fatalf("save text export: %v", err)
}
text, err := os.ReadFile(textPath)
if err != nil {
t.Fatalf("read text export: %v", err)
}
if got, want := string(text), "# 会话\n"; got != want {
t.Fatalf("text export = %q, want %q", got, want)
}
binaryPath := filepath.Join(dir, "session.png")
binary := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0xff}
if err := app.SaveExportFile(binaryPath, base64.StdEncoding.EncodeToString(binary), true); err != nil {
t.Fatalf("save binary export: %v", err)
}
written, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatalf("read binary export: %v", err)
}
if string(written) != string(binary) {
t.Fatalf("binary export = %v, want %v", written, binary)
}
}
func TestSaveExportFileRejectsInvalidBase64(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "broken.pdf")
err := (&App{}).SaveExportFile(path, "not base64!", true)
if err == nil {
t.Fatal("expected invalid base64 error")
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("invalid payload should not create a file, stat error = %v", statErr)
}
}
func TestExportErrorsDoNotExposeSelectedDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missingDir := filepath.Join(dir, "private-export-directory")
payload := base64.StdEncoding.EncodeToString([]byte("image"))
tests := []struct {
name string
path string
run func(string) error
}{
{
name: "single file",
path: filepath.Join(missingDir, "session.pdf"),
run: func(path string) error {
return (&App{}).SaveExportFile(path, payload, true)
},
},
{
name: "multipart image",
path: filepath.Join(missingDir, "session.png"),
run: func(path string) error {
return (&App{}).SaveExportImageFiles(path, []string{payload, payload})
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
err := test.run(test.path)
if err == nil {
t.Fatal("expected missing export directory to fail")
}
if strings.Contains(err.Error(), dir) {
t.Fatalf("export error exposed selected directory: %q", err)
}
if !strings.Contains(err.Error(), "session") {
t.Fatalf("export error should retain a safe file name: %q", err)
}
})
}
}
func TestSaveExportImageFilesWritesNumberedParts(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.archive.png")
payloads := [][]byte{{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}}
encoded := make([]string, len(payloads))
for i, payload := range payloads {
encoded[i] = base64.StdEncoding.EncodeToString(payload)
}
if err := (&App{}).SaveExportImageFiles(path, encoded); err != nil {
t.Fatalf("save image parts: %v", err)
}
for i, want := range payloads {
partPath := filepath.Join(dir, fmt.Sprintf("session.archive-%d-of-3.png", i+1))
got, err := os.ReadFile(partPath)
if err != nil {
t.Fatalf("read image part %d: %v", i+1, err)
}
if !bytes.Equal(got, want) {
t.Fatalf("image part %d = %v, want %v", i+1, got, want)
}
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("multi-part export should not write the selected base path, stat error = %v", err)
}
}
func TestSaveExportImageFilesPreservesSelectedPath(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Windows normalizes trailing spaces in file names")
}
dir := t.TempDir()
selectedPath := filepath.Join(dir, "session.png ")
neighborPath := filepath.Join(dir, "session.png")
if err := os.WriteFile(neighborPath, []byte("keep me"), 0o644); err != nil {
t.Fatalf("seed neighboring file: %v", err)
}
payload := base64.StdEncoding.EncodeToString([]byte("new image"))
if err := (&App{}).SaveExportImageFiles(selectedPath, []string{payload}); err != nil {
t.Fatalf("save exact selected path: %v", err)
}
if got, err := os.ReadFile(selectedPath); err != nil || string(got) != "new image" {
t.Fatalf("selected path data = %q, err = %v", got, err)
}
if got, err := os.ReadFile(neighborPath); err != nil || string(got) != "keep me" {
t.Fatalf("neighboring file changed: data=%q err=%v", got, err)
}
}
func TestSaveExportImageFilesMatchesNormalExportPermissions(t *testing.T) {
t.Parallel()
dir := t.TempDir()
referencePath := filepath.Join(dir, "reference.png")
if err := (&App{}).SaveExportFile(referencePath, base64.StdEncoding.EncodeToString([]byte("reference")), true); err != nil {
t.Fatalf("save reference export: %v", err)
}
payload := base64.StdEncoding.EncodeToString([]byte("image"))
if err := (&App{}).SaveExportImageFiles(filepath.Join(dir, "session.png"), []string{payload, payload}); err != nil {
t.Fatalf("save multipart export: %v", err)
}
referenceInfo, err := os.Stat(referencePath)
if err != nil {
t.Fatalf("stat reference export: %v", err)
}
partInfo, err := os.Stat(filepath.Join(dir, "session-1-of-2.png"))
if err != nil {
t.Fatalf("stat multipart export: %v", err)
}
if got, want := partInfo.Mode().Perm(), referenceInfo.Mode().Perm(); got != want {
t.Fatalf("multipart permissions = %v, want normal export permissions %v", got, want)
}
if matches, err := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); err != nil || len(matches) != 0 {
t.Fatalf("staged files remain after successful export: matches=%v err=%v", matches, err)
}
}
func TestSaveExportImageFilesRejectsCollisionWithoutPartialOutput(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.png")
collisionPath := filepath.Join(dir, "session-2-of-3.png")
if err := os.WriteFile(collisionPath, []byte("keep me"), 0o644); err != nil {
t.Fatalf("seed collision: %v", err)
}
payload := base64.StdEncoding.EncodeToString([]byte("new image"))
err := (&App{}).SaveExportImageFiles(path, []string{payload, payload, payload})
if err == nil {
t.Fatal("expected existing numbered export to reject the batch")
}
if got, readErr := os.ReadFile(collisionPath); readErr != nil || string(got) != "keep me" {
t.Fatalf("existing image part changed: data=%q err=%v", got, readErr)
}
for _, name := range []string{"session-1-of-3.png", "session-3-of-3.png"} {
if _, statErr := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(statErr) {
t.Fatalf("collision should leave no partial output %s, stat error = %v", name, statErr)
}
}
}
func TestSaveExportImageFilesDecodesAllPartsBeforeWriting(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.png")
valid := base64.StdEncoding.EncodeToString([]byte("image"))
err := (&App{}).SaveExportImageFiles(path, []string{valid, "not base64!", valid})
if err == nil {
t.Fatal("expected invalid image payload to reject the batch")
}
for i := 1; i <= 3; i++ {
partPath := filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i))
if _, statErr := os.Stat(partPath); !os.IsNotExist(statErr) {
t.Fatalf("invalid payload should leave no image part %d, stat error = %v", i, statErr)
}
}
if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
t.Fatalf("invalid payload left staged files: matches=%v err=%v", matches, globErr)
}
}
func TestSaveExclusiveExportFilesRollsBackCommittedTargets(t *testing.T) {
t.Parallel()
target := filepath.Join(t.TempDir(), "duplicate.png")
err := saveExclusiveExportFiles(
[]string{target, target},
[][]byte{[]byte("first"), []byte("second")},
)
if err == nil {
t.Fatal("expected duplicate exclusive target to fail")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("failed batch should roll back its committed target, stat error = %v", statErr)
}
}
func TestRollbackDoesNotRemoveReplacedExportTarget(t *testing.T) {
t.Parallel()
dir := t.TempDir()
tempPath := filepath.Join(dir, ".staged.png")
targetPath := filepath.Join(dir, "session.png")
if err := os.WriteFile(tempPath, []byte("staged"), 0o644); err != nil {
t.Fatalf("write staged file: %v", err)
}
created, err := commitStagedExportFile(tempPath, targetPath)
if err != nil {
t.Fatalf("commit staged file: %v", err)
}
tempInfo, err := os.Lstat(tempPath)
if err != nil {
t.Fatalf("stat staged file: %v", err)
}
if !os.SameFile(created, tempInfo) {
t.Fatal("commit must return the staged inode identity")
}
if err := os.Remove(targetPath); err != nil {
t.Fatalf("replace committed target: %v", err)
}
if err := os.WriteFile(targetPath, []byte("replacement"), 0o644); err != nil {
t.Fatalf("write replacement target: %v", err)
}
rollbackCommittedExportFiles([]committedExportFile{{path: targetPath, info: created}})
if got, err := os.ReadFile(targetPath); err != nil || string(got) != "replacement" {
t.Fatalf("rollback removed replacement: data=%q err=%v", got, err)
}
}
func TestConcurrentMultipartExportsHaveSingleCompleteWinner(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "session.png")
encode := func(values ...string) []string {
encoded := make([]string, len(values))
for i, value := range values {
encoded[i] = base64.StdEncoding.EncodeToString([]byte(value))
}
return encoded
}
batches := [][]string{
encode("a-1", "a-2", "a-3"),
encode("b-1", "b-2", "b-3"),
}
start := make(chan struct{})
errs := make(chan error, len(batches))
var ready sync.WaitGroup
ready.Add(len(batches))
for _, batch := range batches {
batch := batch
go func() {
ready.Done()
<-start
errs <- (&App{}).SaveExportImageFiles(path, batch)
}()
}
ready.Wait()
close(start)
successes := 0
for range batches {
if err := <-errs; err == nil {
successes++
}
}
if successes != 1 {
t.Fatalf("successful concurrent exports = %d, want exactly one", successes)
}
first, err := os.ReadFile(filepath.Join(dir, "session-1-of-3.png"))
if err != nil {
t.Fatalf("read winning first part: %v", err)
}
winner := string(first[:1])
for i := 1; i <= 3; i++ {
got, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i)))
if err != nil {
t.Fatalf("read winning part %d: %v", i, err)
}
if want := fmt.Sprintf("%s-%d", winner, i); string(got) != want {
t.Fatalf("winning part %d = %q, want %q from one batch", i, got, want)
}
}
if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
t.Fatalf("concurrent export left staged files: matches=%v err=%v", matches, globErr)
}
}
func TestExportFileFiltersSelectExpectedNativePattern(t *testing.T) {
t.Parallel()
tests := []struct {
mime string
ext string
want string
}{
{mime: "application/pdf", ext: ".pdf", want: "*.pdf"},
{mime: "image/png", ext: ".png", want: "*.png"},
{mime: "application/octet-stream", ext: ".bin", want: "*.bin"},
}
for _, test := range tests {
filters := exportFileFilters(test.mime, test.ext)
if len(filters) != 1 || filters[0].Pattern != test.want {
t.Fatalf("filters for %s = %#v, want pattern %q", test.mime, filters, test.want)
}
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"reasonix/internal/config"
)
const (
externalOpenerFileManager = "file-manager"
externalOpenerEditor = "editor"
externalOpenerTerminal = "terminal"
externalOpenerCatalogTTL = 15 * time.Second
)
// ExternalOpenerView is the renderer-safe description of one installed app.
// Target executable paths and launch arguments stay in the native shell so the
// frontend can never turn this feature into an arbitrary command runner.
type ExternalOpenerView struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
IconDataURL string `json:"iconDataUrl,omitempty"`
}
// ExternalOpenersView is the complete state for the Codex-style Open control.
type ExternalOpenersView struct {
Openers []ExternalOpenerView `json:"openers"`
Preferred string `json:"preferred"`
}
type externalOpenerSpec struct {
View ExternalOpenerView
Target string
LaunchMode string
IconSource string
}
type externalOpenerCatalogCache struct {
mu sync.Mutex
ttl time.Duration
discover func() []externalOpenerSpec
now func() time.Time
loaded bool
loadedAt time.Time
specs []externalOpenerSpec
refreshDone chan struct{}
}
var platformExternalOpenerCatalog = newExternalOpenerCatalogCache(externalOpenerCatalogTTL, platformExternalOpenerSpecs)
func newExternalOpenerCatalogCache(ttl time.Duration, discover func() []externalOpenerSpec) *externalOpenerCatalogCache {
return &externalOpenerCatalogCache{ttl: ttl, discover: discover, now: time.Now}
}
func cloneExternalOpenerSpecs(specs []externalOpenerSpec) []externalOpenerSpec {
return append([]externalOpenerSpec(nil), specs...)
}
func (c *externalOpenerCatalogCache) get() []externalOpenerSpec {
for {
c.mu.Lock()
now := c.now()
if c.loaded && now.Sub(c.loadedAt) < c.ttl {
specs := cloneExternalOpenerSpecs(c.specs)
c.mu.Unlock()
return specs
}
if done := c.refreshDone; done != nil {
c.mu.Unlock()
<-done
continue
}
done := make(chan struct{})
c.refreshDone = done
discover := c.discover
c.mu.Unlock()
specs := discover()
c.mu.Lock()
c.specs = cloneExternalOpenerSpecs(specs)
c.loaded = true
c.loadedAt = c.now()
c.refreshDone = nil
close(done)
result := cloneExternalOpenerSpecs(c.specs)
c.mu.Unlock()
return result
}
}
func cachedPlatformExternalOpenerSpecs() []externalOpenerSpec {
return platformExternalOpenerCatalog.get()
}
// startDetachedExternalOpener reaps the launched process in the background so
// exited children do not pile up as zombies for the desktop app's lifetime.
func startDetachedExternalOpener(cmd *exec.Cmd) error {
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
}
func externalOpenerByID(specs []externalOpenerSpec, id string) (externalOpenerSpec, bool) {
id = strings.ToLower(strings.TrimSpace(id))
for _, spec := range specs {
if spec.View.ID == id {
return spec, true
}
}
return externalOpenerSpec{}, false
}
func resolveExternalOpener(specs []externalOpenerSpec, preferred string) (externalOpenerSpec, bool) {
if spec, ok := externalOpenerByID(specs, preferred); ok {
return spec, true
}
for _, spec := range specs {
if spec.View.Kind == externalOpenerFileManager {
return spec, true
}
}
if len(specs) == 0 {
return externalOpenerSpec{}, false
}
return specs[0], true
}
func externalOpenerViews(specs []externalOpenerSpec) []ExternalOpenerView {
views := make([]ExternalOpenerView, 0, len(specs))
seen := make(map[string]bool, len(specs))
for _, spec := range specs {
id := strings.ToLower(strings.TrimSpace(spec.View.ID))
if id == "" || seen[id] || strings.TrimSpace(spec.View.Name) == "" {
continue
}
spec.View.ID = id
views = append(views, spec.View)
seen[id] = true
}
return views
}
func externalOpenerViewsWithIcons(specs []externalOpenerSpec) []ExternalOpenerView {
withIcons := make([]externalOpenerSpec, len(specs))
copy(withIcons, specs)
for i := range withIcons {
withIcons[i].View.IconDataURL = externalOpenerIconDataURL(withIcons[i])
}
return externalOpenerViews(withIcons)
}
func (a *App) preferredExternalOpenerID() string {
cfg, _, err := a.loadDesktopUserConfigForView()
if err != nil || cfg == nil {
return ""
}
return cfg.DesktopExternalOpener()
}
// ExternalOpeners returns only applications detected on the current machine.
// If a preference was copied from another OS or the app was uninstalled, the
// returned preferred id falls back without rewriting the user's config.
func (a *App) ExternalOpeners() ExternalOpenersView {
specs := cachedPlatformExternalOpenerSpecs()
views := externalOpenerViewsWithIcons(specs)
selected, ok := resolveExternalOpener(specs, a.preferredExternalOpenerID())
if !ok {
return ExternalOpenersView{Openers: views}
}
return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
}
// SetPreferredExternalOpener persists an installed, platform-owned opener id.
func (a *App) SetPreferredExternalOpener(id string) error {
specs := cachedPlatformExternalOpenerSpecs()
spec, ok := externalOpenerByID(specs, id)
if !ok {
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
}
return a.applyConfigOnly(func(c *config.Config) error {
return c.SetDesktopExternalOpener(spec.View.ID)
})
}
// OpenWorkspaceInExternalOpener opens the active workspace using either the
// requested installed app or the persisted/fallback selection when id is empty.
func (a *App) OpenWorkspaceInExternalOpener(id string) error {
return a.OpenWorkspaceInExternalOpenerForTab("", id)
}
// OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
// send the wrong project to an external application.
func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
root, _, ok := a.workspaceTargetForTab(tabID)
if !ok {
return os.ErrNotExist
}
path, err := workspaceBaseFromRoot(root)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("workspace is not a directory")
}
specs := cachedPlatformExternalOpenerSpecs()
var spec externalOpenerSpec
if strings.TrimSpace(id) == "" {
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
} else {
spec, ok = externalOpenerByID(specs, id)
}
if !ok {
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
}
return launchPlatformExternalOpener(spec, path)
}
+180
View File
@@ -0,0 +1,180 @@
//go:build darwin
package main
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func darwinInstalledApplicationIndex() map[string]string {
home, _ := os.UserHomeDir()
roots := []string{
"/Applications",
"/System/Applications",
"/System/Applications/Utilities",
"/System/Library/CoreServices",
}
if home != "" {
roots = append(roots, filepath.Join(home, "Applications"))
}
index := make(map[string]string)
add := func(path string) {
path = strings.TrimSpace(path)
if info, err := os.Stat(path); err == nil && info.IsDir() && strings.HasSuffix(strings.ToLower(path), ".app") {
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
if _, exists := index[name]; !exists {
index[name] = path
}
}
}
for _, root := range roots {
entries, err := os.ReadDir(root)
if err != nil {
continue
}
for _, entry := range entries {
if strings.HasSuffix(strings.ToLower(entry.Name()), ".app") {
add(filepath.Join(root, entry.Name()))
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "/usr/bin/mdfind", "kMDItemContentType == 'com.apple.application-bundle'").Output()
if err == nil {
for _, line := range strings.Split(string(out), "\n") {
add(line)
}
}
return index
}
func darwinApplicationPath(index map[string]string, names ...string) string {
for _, name := range names {
if path := index[strings.ToLower(strings.TrimSpace(name))]; path != "" {
return path
}
}
return ""
}
func platformExternalOpenerSpecs() []externalOpenerSpec {
installed := darwinInstalledApplicationIndex()
var specs []externalOpenerSpec
addMode := func(id, name, kind, mode, _ string, appNames ...string) {
if path := darwinApplicationPath(installed, appNames...); path != "" {
specs = append(specs, externalOpenerSpec{
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
Target: path,
LaunchMode: mode,
IconSource: path,
})
}
}
add := func(id, name, kind, bundleID string, appNames ...string) {
addMode(id, name, kind, "application", bundleID, appNames...)
}
// Keep the order stable and close to the native Codex menu: common editors,
// the platform browser, terminals, then the broader IDE catalog.
add("vscode", "VS Code", externalOpenerEditor, "com.microsoft.VSCode", "Visual Studio Code")
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "com.microsoft.VSCodeInsiders", "Visual Studio Code - Insiders")
add("cursor", "Cursor", externalOpenerEditor, "com.todesktop.230313mzl4w4u92", "Cursor")
add("finder", "Finder", externalOpenerFileManager, "com.apple.finder", "Finder")
add("terminal", "Terminal", externalOpenerTerminal, "com.apple.Terminal", "Terminal")
add("iterm", "iTerm2", externalOpenerTerminal, "com.googlecode.iterm2", "iTerm", "iTerm2")
addMode("ghostty", "Ghostty", externalOpenerTerminal, "ghostty", "com.mitchellh.ghostty", "Ghostty")
add("xcode", "Xcode", externalOpenerEditor, "com.apple.dt.Xcode", "Xcode")
add("android-studio", "Android Studio", externalOpenerEditor, "com.google.android.studio", "Android Studio")
add("goland", "GoLand", externalOpenerEditor, "com.jetbrains.goland", "GoLand")
add("pycharm", "PyCharm", externalOpenerEditor, "com.jetbrains.pycharm", "PyCharm", "PyCharm CE")
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "com.jetbrains.intellij", "IntelliJ IDEA", "IntelliJ IDEA Ultimate", "IntelliJ IDEA CE")
add("webstorm", "WebStorm", externalOpenerEditor, "com.jetbrains.WebStorm", "WebStorm")
add("datagrip", "DataGrip", externalOpenerEditor, "com.jetbrains.datagrip", "DataGrip")
add("codebuddy", "CodeBuddy", externalOpenerEditor, "com.tencent.codebuddy", "CodeBuddy")
add("windsurf", "Windsurf", externalOpenerEditor, "com.exafunction.windsurf", "Windsurf")
add("zed", "Zed", externalOpenerEditor, "dev.zed.Zed", "Zed")
add("sublime-text", "Sublime Text", externalOpenerEditor, "com.sublimetext.4", "Sublime Text")
add("kiro", "Kiro", externalOpenerEditor, "dev.kiro.desktop", "Kiro")
return specs
}
func darwinBundleIconPath(appPath string) string {
resources := filepath.Join(appPath, "Contents", "Resources")
infoPlist := filepath.Join(appPath, "Contents", "Info.plist")
for _, key := range []string{"CFBundleIconFile", "CFBundleIconName"} {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
out, err := exec.CommandContext(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :"+key, infoPlist).Output()
cancel()
if err != nil {
continue
}
name := strings.TrimSpace(string(out))
if name == "" {
continue
}
if filepath.Ext(name) == "" {
name += ".icns"
}
path := filepath.Join(resources, name)
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
return path
}
}
// Older and some Electron bundles omit the icon key. The application icon
// is normally the largest ICNS resource; document-type icons are much smaller.
matches, _ := filepath.Glob(filepath.Join(resources, "*.icns"))
var best string
var bestSize int64
for _, candidate := range matches {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > bestSize {
best = candidate
bestSize = info.Size()
}
}
return best
}
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
iconPath := darwinBundleIconPath(spec.IconSource)
if iconPath == "" {
return ""
}
tempDir, err := os.MkdirTemp("", "reasonix-opener-icon-*")
if err != nil {
return ""
}
defer os.RemoveAll(tempDir)
outPath := filepath.Join(tempDir, "icon.png")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/sips", "-s", "format", "png", "-z", "64", "64", iconPath, "--out", outPath)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Run(); err != nil {
return ""
}
data, err := os.ReadFile(outPath)
if err != nil {
return ""
}
return externalOpenerPNGDataURL(data)
}
func darwinExternalOpenerCommand(spec externalOpenerSpec, path string) *exec.Cmd {
if spec.LaunchMode == "ghostty" {
return exec.Command("/usr/bin/open", "-na", spec.Target, "--args", "--working-directory="+path)
}
return exec.Command("/usr/bin/open", "-a", spec.Target, path)
}
func launchPlatformExternalOpener(spec externalOpenerSpec, path string) error {
return startDetachedExternalOpener(darwinExternalOpenerCommand(spec, path))
}
+64
View File
@@ -0,0 +1,64 @@
//go:build darwin
package main
import (
"reflect"
"strings"
"testing"
)
func TestDarwinExternalOpenersUseNativeApplicationMetadata(t *testing.T) {
specs := platformExternalOpenerSpecs()
finder, ok := externalOpenerByID(specs, "finder")
if !ok {
t.Fatal("Finder must always be present on macOS")
}
if !strings.HasSuffix(finder.IconSource, "Finder.app") {
t.Fatalf("Finder icon source = %q, want native application bundle", finder.IconSource)
}
if icon := externalOpenerIconDataURL(finder); !strings.HasPrefix(icon, "data:image/png;base64,") {
t.Fatalf("Finder native icon = %q, want PNG data URL", icon)
}
if iterm, installed := externalOpenerByID(specs, "iterm"); installed && iterm.View.Name != "iTerm2" {
t.Fatalf("iTerm label = %q, want official iTerm2 name", iterm.View.Name)
}
if ghostty, installed := externalOpenerByID(specs, "ghostty"); installed && ghostty.LaunchMode != "ghostty" {
t.Fatalf("Ghostty launch mode = %q, want working-directory aware mode", ghostty.LaunchMode)
} else if installed {
path := "/tmp/reasonix workspace"
want := []string{"/usr/bin/open", "-na", ghostty.Target, "--args", "--working-directory=" + path}
if got := darwinExternalOpenerCommand(ghostty, path).Args; !reflect.DeepEqual(got, want) {
t.Fatalf("Ghostty command = %#v, want %#v", got, want)
}
}
}
func TestDarwinCatalogIncludesInstalledCodexApplications(t *testing.T) {
installed := darwinInstalledApplicationIndex()
specs := platformExternalOpenerSpecs()
for appName, id := range map[string]string{
"xcode": "xcode",
"android studio": "android-studio",
"pycharm": "pycharm",
} {
if installed[appName] == "" {
continue
}
if _, ok := externalOpenerByID(specs, id); !ok {
t.Errorf("installed %s is missing from external opener catalog", appName)
}
}
}
func TestDarwinInstalledOpenersExposeNativeIcons(t *testing.T) {
for _, spec := range platformExternalOpenerSpecs() {
if spec.IconSource == "" {
t.Errorf("%s has no native icon source", spec.View.Name)
continue
}
if icon := externalOpenerIconDataURL(spec); !strings.HasPrefix(icon, "data:image/png;base64,") {
t.Errorf("%s native icon could not be extracted", spec.View.Name)
}
}
}
Loaded 100 of 1597 files, more files were not shown because too many files have changed in this diff. Show more