Compare commits

...
108 Commits
Author SHA1 Message Date
YHH e6b520c39b fix(config): workspace configuration only narrows the user's settings (#11209)
A workspace's reasonix.toml and .reasonix/settings.json are read as
untrusted input.

- [sandbox]: bash may only move to enforce and network only to false;
  forbid_read adds to the user's; allow_write entries and workspace_root
  apply only when they resolve inside the workspace. ${VAR} in sandbox
  and proxy paths expands from the process environment only.
- [permissions] mode, allow and allow_dynamic_bash, [network] proxy
  settings, [bot], and [desktop] default_tool_approval_mode, language
  and currency are read from the user config only; deny and ask add to
  the user's. An "always allow" or write-access grant given in a
  workspace is stored under the Reasonix home (project-grants.json)
  rather than in the checkout. The desktop no longer adopts a workspace
  reasonix.toml into the user config.
- Programs a workspace declares (hooks, language servers, providers,
  tools.search.rg_path, tools.shell.path and [browser] launch settings)
  run only once approved for that folder. An approval records the
  declaration and a digest of the files it names, is kept under the
  Reasonix home, and is checked again before each start; a change
  refuses the start with ErrProjectProgramChanged. `reasonix trust`
  lists and approves them, and the desktop hooks settings show waiting
  project hooks with an Approve action. Model settings that named only a
  held-back provider fall back to the user's choice.
- Single program paths may not point into the workspace, a temporary
  directory, an allow_write root or a toolchain cache.
- Sandbox write directories are resolved without following links that
  sit inside a writable directory, and host write directories keep the
  identity first seen. On Linux the toolchain caches are listed whether
  or not they exist yet.
- Every ignored value is reported as a load warning with a typed reason
  (Config.IgnoredProjectSettings); held-back hooks raise a notice.
2026-09-28 18:24:30 -07:00
YHH f733345587 Merge pull request #11210 from esengine/docs/v1.39.5-notes
docs(release): prepare v1.39.5 notes
2026-09-28 18:11:41 -07:00
esengine 4f1637e05f docs(release): prepare v1.39.5 notes 2026-09-28 18:09:38 -07:00
YHH 8f7e4a0748 Merge pull request #11190 from KHG420/codex/1x-project-state-collision-10682
fix(desktop): isolate newly added project slug collisions
2026-09-28 11:41:32 -07:00
xiexian 3770cb3856 fix(desktop): satisfy project update lint checks 2026-09-29 01:30:41 +08:00
xiexian 2835c52d2b fix(desktop): preserve legacy project state on re-add and import 2026-09-29 01:19:38 +08:00
YHH 64638bccd1 Merge pull request #11185 from KHG420/codex/1x-validate-permission-rules-6692
fix(desktop): reject permission rules for unregistered tools
2026-09-28 08:52:33 -07:00
YHH ae31f7388e Merge pull request #11188 from esengine/fix/desktop-11168-logo-theme
fix(desktop): tint the title-bar logo with the theme accent
2026-09-28 08:42:49 -07:00
xiexian ade8df6c1f Merge main-v2 into project collision fix 2026-09-28 23:35:09 +08:00
xiexian 420718b717 fix(desktop): explain MCP permission rules require exact tools 2026-09-28 23:21:43 +08:00
esengine 4e8dc79184 fix(desktop): tint the title-bar logo with the theme accent
The title-bar mark was an <img> of logo-symbol.svg, whose fill is fixed
blue, so it ignored the theme and was hard to see on dark themes (about
2.8:1 against the bar). It is now the same SVG used as a mask over
var(--accent), so it follows each theme's accent (4.6-9.7:1 on dark).

The startup splash and the static boot shell keep the brand blue: they
show before or while the theme loads.
2026-09-28 08:21:16 -07:00
xiexian 97f5a265de fix(desktop): accept configured disconnected MCP permission tools 2026-09-28 23:18:08 +08:00
YHH b76822bc19 Merge pull request #11187 from esengine/fix/desktop-legacy-archive-rematerialize
fix(desktop): archiving a legacy version row archives every version of it
2026-09-28 08:17:15 -07:00
xiexian c2974487dd fix(desktop): isolate newly registered project slug collisions 2026-09-28 23:12:33 +08:00
esengine 1d75becebc fix(desktop): archiving a legacy version row archives every version of it
Every rewind keeps the head it left as a live version, and the sidebar
expands each live head of a legacy transcript into its own row carrying
the conversation's title. "Move to trash" archived only the clicked head:
it materialized that version into desktop-sessions-v5 (one mapping, one
session state, one presentation entry) and the next version, same title,
moved up into its place. Repeated clicks walked the versions one by one,
so the list never visibly shrank and each click added a ~2 MB record
pointing at the same legacy transcript.

Re-archiving the same head was already idempotent; the records are
distinct versions, not copies of one.

Archiving a legacy head now also archives the other live heads that are
versions of the same conversation: heads linked to it through rewind
parents, read from the transcript's own head list. Named forks start a
conversation of their own and are left alone, as are retired and
concurrent heads. Each version goes through the existing source archive
path, so it is materialized at most once and a repeat is a no-op.
2026-09-28 07:44:23 -07:00
YHHandesengine d39c0ff888 fix(migrator): refuse a flat unit without the Electron shell and restore the previous release (#11182)
The 1.18.0-1.19.2 file updaters accept the current electron-v1 manifest and
publish only reasonix-desktop, reasonix and reasonix-guard out of the Linux
tarball; app/ never reaches the install. The migrator shipped as
reasonix-guard then activated versions/<v> without a shell, wrote
current.json, committed the legacy update transaction as healthy and removed
the flat files. The new desktop exits with "no Electron desktop shell", and
nothing is left to roll back to.

An absent or incomplete app/ tree now stops the flat migration before any
commit: no version directory, no current.json, no health mark, no cleanup.
When the old updater's transaction is still pending for this install and
version, the migrator rolls it back through the repair transaction (Unix
only) and relaunches the restored release through its Guard, the way the
legacy updater itself relaunches. The refusal carries errShellMissing and is
appended to <home>/desktop-shell/logs/legacy-migrate.log, since a migrator
started by an old updater has no terminal.

Tests reproduce the legacy updater's exact transaction steps from inside the
install (subprocess copies of the test binary, as the repair transaction
binds the running executable to the install directory), then assert the
rollback, and that a unit carrying app/ still commits the transaction.

Co-authored-by: esengine <[email protected]>
2026-09-28 07:29:54 -07:00
xiexian 24dc82f313 fix(desktop): keep permission command contract owner stable 2026-09-28 22:10:42 +08:00
xiexian b73f1174b2 test(desktop): preserve existing rules on rejected save 2026-09-28 21:59:06 +08:00
xiexian d62e151fcb fix(desktop): reject permission rules for unregistered tools 2026-09-28 21:53:03 +08:00
YHH d5c4e196a6 Merge pull request #11171 from SivanCola/codex/context-compaction-recovery
Bound compaction waits and preserve context on failure / 限制整理等待并保留失败上下文
2026-09-28 04:33:53 -07:00
SivanCola e8e7af5996 Document intentional exact error identity checks
Problem: Errorlint rejects direct comparisons in the error-ownership regression
and in the bare-deadline normalization branch.

Root cause: These checks intentionally distinguish an unchanged error from an
additional wrapper; errors.Is alone cannot prove that stronger invariant.

Fix: Document narrowly scoped errorlint exceptions for exact identity checks.
The bare sentinel may be replaced, while enclosing errors retain their wrappers.
No execution behavior changes.

Verification: Repository-pinned golangci-lint reports zero issues, repolint is
clean, and the focused error-boundary regressions pass with race detection.
2026-09-28 19:02:25 +08:00
SivanCola 7e45e66110 Preserve provider failure diagnostics across cleanup and restart / 保留收尾与重启后的模型请求失败诊断 (#11152)
* fix: preserve provider failure diagnostics across cleanup and restart

Problem: Failed requests could reopen without their failure identity or the
transport evidence needed to investigate them.

Root cause: Cleanup replaced terminal recovery metadata, HTTP/2 protocol
errors fell through classification, and request observations were process-local.

Fix: Preserve failure metadata at the shared cleanup boundary, recognize
HTTP/2 transport codes without enabling retries, and persist bounded redacted
request evidence atomically with turn completion. Localize failure summaries
and document additive persistence compatibility.

Verification: Full root Go suite, owning-package and focused race tests,
desktop history/export regressions, frontend tests and typecheck, Chromium
interaction checks, Windows provider cross-compilation, and repolint passed.

* fix: align diagnostic contracts and isolate lifecycle test fixtures

Problem: CI rejected stale desktop contracts and an error type assertion;
Windows lifecycle tests also timed out around filesystem finalization.

Root cause: The additive diagnostic field was not regenerated into the host
contract. Transport classification used a direct error assertion. Functional
checkpoint and cancellation tests unnecessarily coupled live state assertions
to multiple durable writes under short completion deadlines.

Fix: Regenerate the contract, reject wrapped API errors using errors.As, and
use existing in-memory checkpoint/controller fixtures for live semantics.
Retain all assertions and dedicated durable maintenance, checkpoint reload,
and cold-export coverage without changing production timeouts or CI gates.

Verification: Pinned golangci-lint 2.12.2, repolint, focused provider/control
tests, three race-test repetitions, checkpoint persistence reload, frontend
typecheck, and diff checks passed locally.

* fix: structure transport diagnostics and bound failed-turn evidence

Problem: Transport classification depended on error wording, UI projections
dropped structured diagnostics, successful requests persisted peer addresses,
and bounded request history could silently appear complete after eviction.

Root cause: Diagnostic identity was converted back to prose across transport
and presentation boundaries, while lifetime-wide request eviction lacked
bounded per-turn accounting and unsuccessful-turn persistence gating.

Fix: Classify exported HTTP/2 types and exact stdlib type/code identities;
carry diagnostic fields through all notice/history paths; persist only failed,
interrupted, or recovery-required turn evidence; and record per-turn dropped
counts and truncation, marking unavailable accounting incomplete.

Verification: Typed and real TLS connection/stream/GOAWAY tests, provider/control
race checks, cold-export and success-persistence regressions, per-turn loss
tests, frontend projection tests, Chromium three-locale interactions, production
frontend build, and bundle budgets passed. Preserve model bytes and retry policy.

* fix: keep optional diagnostics from blocking terminal commits

Problem: Encoding optional provider evidence could prevent required turn-end
events from committing. Persisted free-text transport errors duplicated the
structured evidence, and generated detail text duplicated HTTP/2 codes.

Root cause: Optional diagnostic errors escaped the shared terminal-event
builder, while transport prose was retained at multiple presentation boundaries.

Fix: Warn and omit failed diagnostic records while preserving required terminal
events. Remove transportError and its byte truncation. Leave transport-code
rendering to the structured frontend presenter. Update export documentation.

Verification: Real invalid-timestamp regression covers failed/interrupted/
recovery-required closures; synchronous and Desktop-send failures retain durable
turn/end and cold-export identity. Structured-evidence, no-prose, presentation,
and model-context tests pass, as do frontend typecheck and affected suites.
2026-09-28 19:01:57 +08:00
SivanCola bf979b7831 Preserve error ownership across compaction entry points
Problem: Compaction entry points relabelled image, extension, persistence and
validation failures as summary errors. An expired safe-pressure operation could
replace a later error with its budget timeout and discard recovery guidance.

Root cause: The shared finish wrapper classified every returned error using the
work context state instead of the owner and identity of the failure.

Fix: Classify provider failures only at the summary request and known summary
outcomes at their owner. Normalize deadlines only when the returned error and
work budget both identify expiry. Preserve wrapped causes and apply localized
context recovery guidance last. Restore oversized protected tool-result
coverage through successful summarization without truncation.

Verification: Regression tests reproduce missing-image classification, safe
timeout followed by extension blocks, persistence identity, compress validation
and localized hard-limit recovery. Check actual summary failure typing and the
protected-result replay with intact canonical history. Run focused race tests,
pinned lint, repolint and the complete root suite; track exact-head PR CI.
2026-09-28 18:57:11 +08:00
SivanCola 1405c2a043 fix: narrow compaction recovery to the 1.x maintenance scope
Problem: The full progress and retry implementation expands the main-v2
maintenance surface beyond the reported compaction hang and truncation defects.

Root cause: The initial change coupled operation deadlines and failure handling
to new runtime, wire, persistence, and UI contracts.

Fix: Retain one shared five-minute compaction budget and typed failure recovery
without automatic lossy truncation. Remove the new observer/progress protocol,
UI, retry API, and receipt fields from the final diff. Preserve the existing
range-compression behavior and last committed projection. Keep the complete
implementation on a separate branch for independent 2.x evaluation.

Verification: Focused Agent/Control and race tests, actual SSE heartbeat parsing
under virtual time, nested/chunk budgets, queue cancellation, late completion,
partial commits, persistence reopening, pinned Go lint, repolint, and diff checks.
The complete root suite and current-head CI provide separate broad evidence.
2026-09-28 18:35:43 +08:00
SivanCola ca4fbe80aa test: complete the compaction runtime snapshot fixture
Problem: The Desktop CI frontend suite rejects the new compaction fixture.

Root cause: The snapshot omitted the required activityRevision field, which
the production-only typecheck excludes along with test sources.

Fix: Supply the initial activity revision in the orphaned-operation snapshot.

Verification: pnpm test:typecheck and the focused context-compaction-progress
test both pass. No production behavior changes.
2026-09-28 18:14:52 +08:00
SivanCola 9c3579256d fix: bound context compaction and preserve failed-session state
Problem: Near-full conversations can wait indefinitely for a summary and lose
additional model-visible context when summarization fails.

Root cause: Auxiliary response buffering hides progress, heartbeat traffic
extends transport idle timers, and overflow recovery falls back to lossy
truncation without an operation-wide deadline.

Fix: Share a cancellable five-minute compaction budget, observe content-free
generation progress, remove truncation recovery, preserve atomic save ownership,
and expose durable terminal state with session-bound compaction-only retries.
Keep initial and switched session runtime bindings available to the Stop UI.

Verification: Root Go suite, affected Desktop suites, deterministic and race
checks, frontend typecheck/transcript/browser gates, generated contracts, and
repository lint. An actual macOS Electron package passed the production
300-second heartbeat timeout, stop, switching, restart, message/draft retention,
retry-only behavior, and clean shutdown. Real providers and Windows/Linux native
packages remain unverified; an extra unpartitioned Desktop sweep timed out.
2026-09-28 18:06:36 +08:00
YHH 5d88b179b2 Merge pull request #11156 from esengine/docs/v1.39.4-notes
docs(release): prepare v1.39.4 notes
2026-09-28 00:40:41 -07:00
esengine 1132215f4a docs(release): prepare v1.39.4 notes 2026-09-28 00:39:28 -07:00
YHH d9ba90ed04 Merge pull request #11037 from esengine/dependabot/go_modules/main-v2/github.com/modelcontextprotocol/go-sdk-1.8.0
build(deps): bump github.com/modelcontextprotocol/go-sdk from 1.8.0-pre.2 to 1.8.0
2026-09-27 23:41:28 -07:00
esengine 4a09d5546b build(deps): tidy the desktop module for go-sdk 1.8.0 2026-09-27 23:26:08 -07:00
dependabot[bot] 4a5f86a0d1 build(deps): bump github.com/modelcontextprotocol/go-sdk
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.8.0-pre.2 to 1.8.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.8.0-pre.2...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-28 05:55:05 +00:00
YHH 555b96966c Merge pull request #11144 from esengine/fix/v1-chat-tui-budget
refactor(cli): move the custom status-line command out of chat_tui.go
2026-09-27 22:38:25 -07:00
esengine 790c6be08e refactor(cli): move the custom status-line command into its own file
chat_tui.go crossed its repolint file-size budget once #11114 and #11062
landed together. The status-line command runner is self-contained, so it
moves verbatim to chat_tui_statusline.go; no behaviour changes.

The freed budget is reclaimed for chat_tui.go only (file-size 4112 -> 4067,
function-size 1056 -> 1030, complexity 274 -> 271, essay 77 -> 75) and the
repo-wide limits drop by the same amounts.
2026-09-27 22:03:08 -07:00
YHH 52f465e4f5 Merge pull request #11038 from esengine/dependabot/npm_and_yarn/desktop/frontend/main-v2/npm-1x-60baa74da5
build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
2026-09-27 21:04:35 -07:00
YHH 3d129f0d6f Merge pull request #11036 from esengine/dependabot/go_modules/main-v2/go-1x-b6c41911f7
build(deps): bump the go-1x group with 5 updates
2026-09-27 21:03:13 -07:00
YHH e66c50c76b Merge pull request #10949 from boscocp/fix/v1-6692-validate-rule-tool-names
fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
2026-09-27 21:02:31 -07:00
YHH b474d12fdd Merge pull request #11076 from esengine/fix/v1-8327-deterministic-reset-fixture
test(openai): pin the body-phase reset fixture to the body phase
2026-09-27 21:01:48 -07:00
YHH b66fdb30f6 Merge pull request #11089 from esengine/fix/v1-9029-drop-into-group
fix(desktop): dropping a session on a group member row adds it to that group
2026-09-27 21:01:03 -07:00
YHH 092ab722cc Merge pull request #11085 from esengine/fix/v1-8859-needs-checks-chime
fix(desktop): ring the attention chime when a turn stops for delivery checks
2026-09-27 21:00:18 -07:00
YHH 390f996103 Merge pull request #11122 from esengine/fix/v1-11010-rotate-typed-key-in-place
fix(setup): rotate a key in place when its providers alone read the variable
2026-09-27 20:59:32 -07:00
YHH 46dd766262 Merge pull request #11093 from esengine/fix/v1-9652-review-step-cap
fix(agent): stop capping review sub-agents at eight rounds
2026-09-27 20:58:46 -07:00
YHH 4cf81e89fe Merge pull request #11062 from esengine/fix/v1-4806-mcp-retry-async
fix(cli): run MCP manager connects off the UI loop
2026-09-27 20:58:01 -07:00
YHH 63582bc7c8 Merge pull request #11092 from KHG420/codex/1x-remote-context-panel
fix(desktop): load remote session context in overview
2026-09-27 20:57:19 -07:00
YHH 54c73168c3 Merge pull request #11131 from esengine/docs/v1.39.3-notes
docs(release): prepare v1.39.3 notes
2026-09-27 19:38:39 -07:00
esengine 36c1faba14 docs(release): prepare v1.39.3 notes 2026-09-27 19:33:09 -07:00
YHH 1808ff6d63 Merge pull request #11124 from esengine/fix/v1-remote-transcript-v2
fix(serve): advertise transcript-v2 whenever the foreground can follow
2026-09-27 19:30:53 -07:00
YHH 9c3123c7a3 Merge pull request #11114 from esengine/fix/v1-hardening-0928
fix: harden project config, serve approvals, host git and sandboxed git metadata
2026-09-27 19:23:42 -07:00
esengine aecf4e6960 fix(serve): advertise transcript-v2 whenever the foreground can follow
The handshake announced transcript-v2 only while the serve's foreground
controller was bound to an exclusive (canonical) session. Opening a
historical path-backed session over SSH makes POST /resume swap the
foreground for a native-legacy controller, and from then on every
/auth/token answer dropped transcript-v2 even though GET
/transcript/follow keeps serving that controller through the legacy
projection. Any remote tab that attached or reconnected in that state
kept a capability set without transcript-v2 for its lifetime, so the
desktop refused Follow before sending a request ("transcript v2 is
required; upgrade Serve and Desktop together") and the composer refused
to send ("Transcript v2 is not synchronized"). The detached serve
survives desktop restarts, so the state persisted across upgrades.

transcript-v2 is now announced from control.TranscriptFollowAPI, the
same interface the route enforces, as transcript-outline-v1 already is.

ServeCapsToken is bumped so a desktop carrying this fix retires a live
serve started from an older binary and replaces a remote binary that
still advertises the old token; otherwise a reused serve would keep the
old handshake until it happened to exit. The same bump retires 1.38.0 to
1.38.9 binaries, which carried the previous token but predate Follow v2.

Test: TestRemoteFollowAttachesToServeWithPathBackedForeground puts a
path-backed controller in a real serve, performs the real token
handshake and follows through RemoteTranscriptFollowForTab. Before the
change it fails with "transcript v2 is required"; after it, the follow
returns a protocol-2 snapshot from the legacy backend.

Fixes #11118
Fixes #11105
2026-09-27 18:58:09 -07:00
esengine d4e11410cb perf(gitcmd): skip the driver listing for plumbing that converts no content
Every hardened invocation listed the repository's filter and merge drivers
with a second git process first, doubling the process count of host git
work. On Windows, where a process start is expensive, that pushed the
worktree suite past its 8-minute limit.

rev-parse, rev-list, merge-base, the ref commands, write-tree and
commit-tree never pass content through a filter or merge driver and never
honour submodule.recurse, so any overrides the listing produced were inert
for them. They now run without it; every subcommand that can convert
content keeps the listing and still fails closed on an unaddressable
driver name.
2026-09-27 18:57:32 -07:00
esengine 2f7809c10d fix(setup): rotate a key in place when its providers alone read the variable
Saving a new key for a provider that already had one always moved the
provider to a fresh REASONIX_CONNECTION_*_KEY slot and left the previous
value under the old variable. A name the user typed at setup (kept since
#11005) therefore went stale on the first rotation, and scripts reading
it kept seeing the old key.

RotateModelCredentialLocked rewrites the variable in place when the edit
targets the user config, the providers being saved are its only readers
there before and after the edit, no bot or remote-host setting reads it,
and the global .env already holds its value. A project reasonix.toml
cannot see the user config's readers, so its providers never rotate.
Anything else still gets a private slot.

Rollback needs the previous value back. It is written to the global .env
under a random REASONIX_ROTATION_*_KEY in the same atomic file write as
the new value, so it has exactly the protection every stored key has;
the journal records only the two names and a keyed digest of the new
value. Cleanup of a failed save and crash recovery restore the previous
value only while the variable still holds the digest this edit wrote,
then drop the backup; a published edit drops the backup and keeps the
new value.

Used by the CLI setup commit, CommitConnectionCredential and the
desktop's journalled credential edits. Desktop edits that run without a
journal keep minting private slots, since they have nothing to restore
from.

Closes #11010
2026-09-27 18:52:02 -07:00
esengine d2cb0aa894 fix(sandbox): collapse over-budget git groups to one bwrap mount
An over-budget worktrees/ or modules/ group was bound read-only as a whole,
but the exact mounts for the gitdirs inside it were still emitted first, so
planting submodule gitdirs kept growing the bubblewrap argument list. Paths
beneath a read-only tree are now left to that tree.

The serve launch-token sandbox test placed its files under /tmp outside the
write root, which bubblewrap masks with a tmpfs: the control read failed and
the denied reads passed without exercising the forbid-read mounts. The files
now live inside the write root on every platform.

The auth-disabled serve launch-token test now closes the process-wide usage
catalog serve.New starts before its REASONIX_HOME is removed, which Windows
otherwise refuses. The partial-clone test compares errors with errors.Is.
2026-09-27 18:37:19 -07:00
esengine 3cb73b3bf4 fix(sandbox): keep repository git config and hooks read-only inside the bash sandbox
The host's own git reads the workspace repository's configuration and hooks,
so a confined command that can rewrite them can make the host run a program
of its choosing. The OS sandbox now denies writes to them while leaving the
rest of .git writable.

For the repository git itself discovers from each writable root, protected
paths are: config, config.worktree, commondir and hooks/ of the gitdir and
of the common dir; config, config.worktree and commondir of every existing
linked worktree entry (new entries stay creatable); config, config.worktree,
commondir and hooks/ of every submodule gitdir under modules/. A .git file is followed to the gitdir it names
exactly as git resolves it, and the pointer file and commondir are
protected, so a repository cannot aim the protection at a decoy while git
reads something else. The .git entry, the gitdir, the common dir, every
symlink on the way to them and each writable ancestor are pinned: they
cannot be removed, renamed away or replaced by a symlink, while their own
mode and times stay writable (touch and chmod on the workspace root work).

- macOS: deny rules after every write allowance. Existing worktree and
  submodule gitdirs (up to 128 each, found without following symlinks) get
  exact rules and pins; patterns anchored at the common dir cover the rest
  and gitdirs created later, skipping refs/ and logs/ segments so a branch
  or tag named config or hooks stays writable. The profile stops growing
  past the limit.
- Linux: pinned directories become their own bind mounts and protected files
  and hook trees are bound read-only; only real directories and regular
  files are mount targets. Up to 512 gitdirs per group get exact mounts;
  past that the whole group directory is bound read-only, so planted HEAD
  files cannot overflow the argument list. Only existing paths can be
  mounted and symlinks cannot be pinned.
- Windows has no shell sandbox; the guide says so.

A protected file with another hard link refuses the confined launch with
sandbox.git_metadata_linked (ErrGitMetadataLinked), since no path rule sees
a write through the other name. Whenever a confined command's output names
a protected path, also on exit 0 (git reports some refused config writes
and exits 0), the bash result carries sandbox.git_metadata_protected.

Real-sandbox tests cover each protected path, pointer and symlink
retargeting, patterns for gitdirs created later, bounded rule size with
2000 submodule gitdirs, ordinary git work, and the git operations that now
fail or silently skip their config write.
2026-09-27 17:35:16 -07:00
esengine ec866730e0 fix(gitcmd): host git never runs repository-configured programs
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
Driver neutralization now covers every subcommand, not diff alone, and takes
driver names from git itself rather than from a separate reading of .git/config.

- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
  which git before 2.35.2 reads as a hook name), core.hooksPath,
  log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
  maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
  for checkout, switch, restore, reset, merge and read-tree (host branch
  switches and merges never update submodules, whatever the user's global
  submodule.recurse), and elsewhere only where the repository's own config
  sets it.
  GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
  repository's remote (git 2.44+); a historical patch that fails for that
  reason returns gitcmd.ErrObjectNotLocal, decided by git's own
  --missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
  carry --ignore-submodules=dirty. The subcommand is found past global
  options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
  git itself before each invocation (includes followed) and overridden empty;
  user and system scope stay live; a name -c cannot address fails closed
  (ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
  worktree, so a conditional include keyed on the new gitdir or branch is
  seen by the driver listing there rather than run by the add's checkout.
  The reset takes the worktree-mutation timeout, and a failed reset removes
  both the worktree and the branch the add created.
  worktree remove uses --force after the existing hardened clean-tree check,
  so git does not run status inside the linked worktree with its config.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
  taken from the launcher's declared env), requires an https://, ssh:// or
  git:// remote (case-insensitive; http:// and local paths are refused), and
  passes the remote and ref past -- so a leading-dash value is an operand.
- worktree git calls with extra environment keep gitcmd's environment.

- Repository identity is resolved once, when a session opens: boot runs
  gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
  before any command, the controller carries the resulting gitcmd.Repo, and a
  rebuild of the same workspace passes it on. Every later host call runs
  through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
  nested .git, a commondir file, a rewritten .git file or a damaged HEAD
  written afterwards cannot substitute another repository or its config; a
  damaged HEAD fails closed. Threaded through the TUI status line, cli
  review (resolved once before the review agent runs), the desktop changes
  panel, change detail, history, commit detail, branch list/checkout/create
  and tab branch label (the tab session's identity; a tab with no session
  yet resolves at display), worktree Inspect/Create/RollbackCreate, and the
  whole merge-back flow: the source and worktree identities are recorded in
  the merge metadata at creation (the worktree's resolved right after
  worktree add) in metadata.repo.json beside metadata.json, whose shape
  older builds decode strictly and so is unchanged; every inspect,
  auto-commit, merge and cleanup call runs against them, and a worktree
  without that file resolves them from the roots.
  InspectMerge finds the allocation root from the managed layout instead of
  asking git in the worktree.
- Repo.Verify applies Open's test to a stored identity; the identities read
  from metadata.repo.json are verified before the merge flow uses them.
- Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as
  directories and untracked nested repositories are excluded with
  :(exclude,literal) pathspecs, because add -A checks a submodule by running
  git inside it under its own config. A gitlink keeps the commit the index
  records; an untracked nested repository leaves the worktree unverifiable
  and the auto-commit refuses. The worktree state token no longer runs git
  inside a changed nested repository.
- Open accepts only a git dir belonging to the work tree that holds the
  directory: the work tree's own .git (sharing no other common dir) or a
  linked worktree's admin dir under <common>/worktrees whose gitdir file
  names that work tree's .git. A .git file or core.worktree pointing at
  another checkout is ErrNotRepository. Submodule checkouts and
  --separate-git-dir repositories opened directly are therefore treated as
  not version-controlled.

Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create, linked-worktree config on RollbackCreate),
the status readout, the launcher's non-URL remote rejection, identity
pinning after a nested .git, a commondir file and a damaged HEAD (config
origin and filter marker), Open refusing another checkout's git dir (.git
file, core.worktree, foreign admin dir), StageAll with nested repositories
named like pathspec magic or globs, metadata.json read by a strict older
decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink
with its own filter, merge-back after the worktree's .git file is pointed at
another git dir, the status line and desktop changes panel of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
2026-09-27 17:35:16 -07:00
esengine 3065db7a49 fix(serve): changes and approvals require the launch token
With authentication off (still `serve`'s default), reads stay open but every
state-changing request needs the per-launch token, as an Authorization bearer
or the cookie /auth/token issues, and otherwise refuses with 403
`launch_token_required`. The gate runs inside the host and CSRF guards, and a
configured token is honoured so a fronting proxy can inject it. Token mode also
accepts the bearer header, and the web UI explains the refusal.

The token never reaches the terminal: in both token and none modes serve writes
it to a 0600 file under the remote state directory and prints the path, or
names the --token-file of a managed launch. Runtime sandboxes and read tools
deny that directory and any registered token file.

[serve] is read from the user config only, so a project reasonix.toml cannot
choose the token, the auth mode, or trust in forwarded headers.
2026-09-27 17:35:00 -07:00
esengine ae2e9053f5 fix(mcp): project-declared MCP servers and the statusline wait for the user
MCP servers a workspace's reasonix.toml or .mcp.json declares, and the
[statusline] command, come with the checkout. Neither may start before the
user decides; this change covers those two surfaces only.

MCP servers:
- Without a recorded decision a project-declared server (stdio command or
  HTTP/SSE URL) is off. User-level servers keep following auto_start. An
  unreadable activation store and an entry of unknown provenance fail closed.
- A decision holds for the declaration it was made on: the activation row
  stores a digest of the declaration as written, the project .env values it
  expands, and the content of workspace files named directly as its command
  or arguments. A change to any of these reads as changed_since_enabled and
  keeps the server off. Files loaded indirectly, PATH-resolved interpreters
  and unpinned package launchers are outside the digest.
- Edits made through Reasonix at the user's request (desktop edit, tier,
  clear-auth, the /mcp mode and clear-auth actions) carry the decision only
  when the file afterwards holds the declaration that edit wrote and the
  .env values and named files it draws on read as in the approved state.
- MCPDecision is typed: enabled, disabled, awaiting_user_decision,
  changed_since_enabled. boot.Build emits one notice per process for each
  such server; `reasonix mcp list` shows the state; the model's refusal
  carries the code instead of "disabled in this session". The launch line
  names every env key, shows values of loader, interpreter and package-index
  variables, redacts endpoint credentials, and points to `reasonix mcp get`.
- `reasonix mcp enable/list/get` resolve the workspace a session there would
  use (nearest git root), and enable prints the approved launch line.
- Starting a project server by hand (/mcp connect, desktop reconnect or tier)
  records the decision. boot.Build, and with it CLI/TUI, `reasonix run`,
  serve, ACP, bot and desktop tabs, reads the same state, as do the /mcp
  view, desktop capabilities, controller fallbacks and `doctor --live`.
- A project .env expands only project-declared servers.

Statusline: user-level only. LoadForRoot restores the user value after the
project merge, project renders never write it, and the TUI spawns it with
NoDefaultCurrentDirectoryInExePath set.
2026-09-27 17:35:00 -07:00
esengine c2d10fb7e0 fix(agent): stop capping review sub-agents at eight rounds
A review, security-review or team-architect sub-agent paused after 8
tool-call rounds whenever the call did not pass max_steps, which is how
every skill-run review and most model-dispatched reviews start. The pause
message tells the user to raise max_steps, but no setting reaches this
budget, so a multi-file review could not finish at all (#9652).

Root cause: applyReviewBudget stamped a hardcoded defaultReviewMaxSteps = 8
on review-family children, and PrepareReviewSubagentContext handed the same
8 to skill runners, overriding the step budget the runner had already
resolved from the parent. Under the default uncapped parent that turned
"no limit" into 8.

Fix: the review contract keeps what it is for (the 2048-token cap per
response, the structured output format and the parent facts pack) and no
longer sets a step budget. Review children take the ordinary child budget:
an explicit max_steps wins, otherwise half the parent's cap, or uncapped
under an uncapped parent. The skill path keeps the runner's own steps.

Test: TestReviewSubagentUnderUncappedParentFinishesPastEightRounds runs a
real review task through TaskTool.Execute with a scripted 12-round child;
before the fix it fails with "sub-agent: paused after 8 tool-call rounds",
after it returns the verdict. TestReviewBudgetKeepsInheritedStepsAndBoundsOutput
(steps = 8, want 0 before) and TestReviewSkillKeepsRunnerStepBudget
("runner steps 0 reached the child as 8" before) cover the task and skill
budgets. go test ./internal/agent/ ./internal/boot/, go vet, repolint and
golangci-lint on both packages pass on Windows.
2026-09-27 09:35:34 -07:00
esengine 053136c488 fix(desktop): dropping a session on a group member row adds it to that group
A drop on a member row of a session group ran the row's reorder, so a
session dragged from outside the group stayed outside it; only the group
header accepted drop-to-group. The row drop now reads the target row's
group from the organization state: a session that is not yet a member is
set into that group, and a session already in it is reordered as before.
2026-09-27 09:15:30 -07:00
xiexian 49939444ff fix(desktop): load remote session context in overview 2026-09-27 23:57:53 +08:00
esengine 3a946c048d fix(desktop): ring the attention chime when a turn stops for delivery checks
A turn that ends with the final-readiness check failing ("need checks"),
or with another typed pause, finished silently: the runtime notification
owner played the success chime only when turn_done carried no error, and
these pauses carry the typed pause as their error. The user got no
signal that the turn was over and waiting on them.

turn_done now reads its structured outcome: final_readiness,
recovery_paused, incomplete_read and completion_uncertain - the outcomes
desktop/topic_status.go records as paused or awaiting_delivery - ring
the attention chime ("Awaiting response"). A clean finish keeps the
success chime and an ordinary failure stays silent.

Test: runtime-turn-done-chime.test.ts drives createRuntimeNotifications
with a fake AudioContext. On the base it fails ("a turn that stopped for
delivery checks must alert the user": 0 !== 1); with the fix it passes.
2026-09-27 08:47:03 -07:00
esengine 54f4898b2c test(openai): pin the body-phase reset fixture to the body phase
TestStreamSurfacesEarlyConnResetAsInterrupt and
TestStreamDoesNotReplayAfterOutput forced a real TCP RST right after
writing the 200 head. Whether net/http observes that RST before or after
the head is up to the OS scheduler (#8327). When it lands first the
failure is a request error returned by Stream itself, not a body-phase
interruption, so the test fails on "Stream:" instead of exercising the
contract it names. On the studio line, which still retries that phase,
the same race shows up as extra server requests.

The fixture is now a scripted RoundTripper: a 200 SSE response whose body
yields the prelude and then fails with a net.OpError(ECONNRESET). The
phase is fixed, the request counter is atomic, and no socket is involved.

Test: both tests pass 500/500 on Windows. Disabling the IsConnReset branch
in streamOnce makes both fail, so the fixture still guards the contract.
2026-09-27 08:26:46 -07:00
esengine 03254360ee fix(cli): run MCP manager connects off the UI loop
Choosing Retry (or Reconnect / Enable and connect) on a server in the /mcp
manager called ConnectConfiguredMCPServer synchronously inside the Bubble
Tea Update. The initialize + tools/list handshake is bounded only by the
server's startup timeout (30s by default), so an unresponsive server froze
every keypress and redraw of the chat until the handshake gave up, which
reads as a hang. Saving a connection mode and the reconnect after MCP
OAuth made the same synchronous call.

All three now start the connect as a tea.Cmd and report back through
mcpConnectDoneMsg, whose cause picks the notice. chatTUI.mcpConnecting
records servers with a connect in flight; it lives on the chat model
rather than the manager, so closing and reopening /mcp cannot start a
second handshake. While a server is connecting, Mode, Retry, Auth, Clear
auth, Disable and Remove in the manager, and /mcp connect|remove, are
refused, so a late result cannot re-enable something the user just turned
off. If a connect still succeeds for a server that is no longer configured
(for example the config was edited meanwhile) or is disabled for the
session, the result disconnects it instead of keeping it live. The result
refreshes the manager without moving it to another page.

Test (internal/cli/mcp_manager_connect_test.go, gated HTTP MCP fake):
- TestMCPManagerRetryDoesNotBlockTheUILoop: on main-v2 applyMCPAction held
  the loop for the full startup timeout (3.0s with a 3s timeout) and
  failed. Now it returns at once; after closing and reopening /mcp a
  second Retry is refused; Disable and Remove are refused while in flight;
  the successful result clears the marker, leaves the manager's stage, and
  a Disable afterwards takes effect.
- TestMCPConnectFinishingAfterRemovalIsDropped: the entry is removed from
  reasonix.toml while initialize is held; the finished connect is
  disconnected. Without the completion check it stayed live (fails).
- TestApplyMCPModeRecordsPluginConnectFailure (moved from mcp_test.go)
  now drives the returned command and still records the host failure.
2026-09-27 07:34:09 -07:00
esengine 35fc8b746a build(deps): tidy the desktop module for the go-1x bumps
desktop/ replaces reasonix with the parent module, so the root bumps raise
its graph too and its go test stops with "updates to go.mod needed".
go mod tidy in desktop/ moves only the versions the root already requires.
2026-09-27 07:22:11 -07:00
esengine befb5e258c build(deps): sync desktop/pnpm-lock.yaml with the npm-1x bumps
Dependabot updated desktop/frontend/package.json but not the workspace
lockfile at desktop/pnpm-lock.yaml, so every frozen install failed with
ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 10.34.5 --lockfile-only;
only the four bumped packages and their own dependencies move.
2026-09-27 07:10:35 -07:00
Adalberto Pereira 8e9f7cfed0 fix(permission): name an unclosed subject and stop widening allow rules / 提示未闭合括号并避免放宽 allow 规则
A rule missing its closing parenthesis is now reported as that typo and
repaired instead of being wrapped in a second Bash(...) that parses but
still matches nothing, and a bare command on the allow list is suggested
in its exact form, since the prefix form would also grant every argument.
2026-09-27 10:58:48 -03:00
YHH 96297a2146 Merge pull request #11047 from KHG420/codex/1x-acp-skill-raw
fix(acp): preserve typed skill input in 1.x turns
2026-09-27 06:41:47 -07:00
YHH 48b340a877 Merge pull request #11051 from esengine/fix/v1-release-without-website
ci(release): publish 1.x without deploying or verifying the website
2026-09-27 06:16:22 -07:00
esengine d67f2835ac ci(release): publish 1.x without deploying or verifying the website
The website is released from its own branch and renders the changelog from
that branch's catalog, so the 1.x publication could dispatch Pages and still
fail on a changelog page it does not own (v1.39.2: "public changelog returned
HTTP 404" after every product surface had shipped).

- Postflight now verifies tags, CLI and Desktop releases, npm, the Desktop
  Stable manifest on dl.reasonix.io and the Homebrew cask, attaches
  release-event.json, and completes. sync-release-site.sh becomes
  reconcile-release-publication.sh without the Pages dispatch/watch stages.
- The stable manifest check stays: it reads dl.reasonix.io/latest/latest.json,
  the Desktop updater pointer on R2, not the website. The Homebrew check moves
  out of the site verifier and reads the tap through the contents API.
- Drop the homepage/changelog/hydrated-DOM probes, observe-release-site.sh,
  release-site-fetch, and the pages.yml dispatches in release-stable.yml and
  release-desktop.yml together with the actions: write they needed.
- Ledger: the site fragment becomes a pointer fragment (stableManifest,
  homebrew); homepage and changelog surfaces are gone.
2026-09-27 05:55:11 -07:00
xiexian 6512f0db40 fix(acp): exclude resolved 1.x skills from routing 2026-09-27 20:35:52 +08:00
dependabot[bot] a0ac937c65 build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
Bumps the npm-1x group in /desktop/frontend with 4 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [katex](https://github.com/KaTeX/KaTeX), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-virtual` from 3.14.11 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/[email protected]/packages/react-virtual)

Updates `katex` from 0.18.7 to 0.18.9
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.18.7...v0.18.9)

Updates `@typescript-eslint/parser` from 8.70.0 to 8.70.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.1/packages/parser)

Updates `tsx` from 4.23.11 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.15)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: katex
  dependency-version: 0.18.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:54:01 +00:00
dependabot[bot] 15a1f262fb build(deps): bump the go-1x group with 5 updates
Bumps the go-1x group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) | `2.0.9` | `2.0.10` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.0` | `4.10.2` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.4` | `1.18.7` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.29` | `0.0.30` |
| [mvdan.cc/sh/v3](https://github.com/mvdan/sh) | `3.14.0` | `3.14.1` |


Updates `charm.land/bubbletea/v2` from 2.0.9 to 2.0.10
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.9...v2.0.10)

Updates `github.com/bmatcuk/doublestar/v4` from 4.10.0 to 4.10.2
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.2)

Updates `github.com/klauspost/compress` from 1.18.4 to 1.18.7
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.4...v1.18.7)

Updates `github.com/mattn/go-runewidth` from 0.0.29 to 0.0.30
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.29...v0.0.30)

Updates `mvdan.cc/sh/v3` from 3.14.0 to 3.14.1
- [Release notes](https://github.com/mvdan/sh/releases)
- [Changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mvdan/sh/compare/v3.14.0...v3.14.1)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: mvdan.cc/sh/v3
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:53:34 +00:00
YHH 7574f8b4c9 Merge pull request #11031 from esengine/docs/v1.39.2-notes
docs(release): prepare v1.39.2 notes
2026-09-27 04:39:01 -07:00
esengine d945284cec docs(release): prepare v1.39.2 notes
Bilingual Stable notes for everything merged to main-v2 since v1.39.1: unopenable and unarchivable sessions, live streaming on legacy-backed sessions, typed skill invocations, the setup API key variable name, and code-page output on Chinese Windows. CI-only changes (#10863, #10870, #10983) are left out.
2026-09-27 04:37:09 -07:00
xiexian c55c6bee78 fix(acp): preserve raw text across slash prompt expansion 2026-09-27 19:30:06 +08:00
YHH 2a3855f16a Merge pull request #11023 from KHG420/codex/1x-inline-skill-raw
fix(skill): route 1.x inline skills from raw user input
2026-09-27 04:20:36 -07:00
YHH 6d0c756228 Merge pull request #11022 from esengine/fix/v1-archived-row-stays-listed
fix(desktop): hide a historical row once any workspace has adopted its source
2026-09-27 03:41:03 -07:00
esengine dd9b441f99 fix(desktop): hide a historical row once any workspace has adopted its source
The sidebar lists a legacy transcript under the scope its sidecar names, and
the persist observer indexes a save under the global fallback when the sidecar
names none. Archive and import assign the owning workspace from the store the
file physically lives in. When the two disagree, the adopted source was only
filtered from the workspace that owns the mapping, so the row stayed listed in
the other one: archiving it "succeeded" again and again, and pin, rename and
open resolved to the archived conversation and were refused with
"Restore this session before ...".

Adoption is a property of the source identity, so the sidebar's adopted-row
filter and the lazy page's path exclusions now consider every mapping, as the
history listing already did. A head-keyed mapping owned by another workspace
that cannot prove a path alias still does not disable the lazy adapter here.
2026-09-27 03:21:47 -07:00
YHH acf3bfdcbc Merge pull request #11003 from KHG420/codex/1x-worktree-git-write-hint
fix(sandbox): guide worktree Git metadata write approval
2026-09-27 03:00:13 -07:00
xiexian 96b296cd90 test(skill): reject slash mentions as implicit invocations 2026-09-27 17:55:14 +08:00
xiexian b18f84ee22 fix(skill): route inline invocations from raw user input 2026-09-27 17:38:47 +08:00
YHH 29c8349c3a Merge pull request #11007 from esengine/fix/v1-11004-inline-skill-pin
fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
2026-09-27 02:26:16 -07:00
YHH 51fe2e9255 Merge pull request #11005 from esengine/fix/v1-11002-api-key-env-name
fix(setup): keep an API key variable name the user types
2026-09-27 02:17:10 -07:00
xiexian 8cf5b64418 test(sandbox): compare JSON-escaped worktree hints on Windows 2026-09-27 17:16:21 +08:00
esengine 463840813f fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
A bare "/name" line or a lone skill chip reached the provider as the raw
rendered skill body in plain user prose: no <skill-pin> sentinel, and on the
structured (composer) path the typed text trailed as an unlabeled paragraph
instead of an Arguments line. The model-invoked path (run_skill,
read_only_skill, read_skill) already sent the pinned form, so one skill had
two payload shapes depending on who invoked it.

Every invocation site now renders through skill.RenderInvocation: the
string submit path, the structured path, RunSkill (ACP prompts) and the
slash_command tool. A lone inline chip takes the typed text as its
arguments, matching "/name task"; several inline chips, or chips mixed with
subagents, keep appending the shared text once. Nothing moves into the
cache-stable prefix. Store.Render is removed and Render is unexported, so the
pinned form is the only one an invocation can take.

An effect test through the real Build asserts the pinned payload at the
provider boundary for the bare and argument-carrying forms of both paths.

Fixes #11004
2026-09-27 02:09:43 -07:00
esengine c04f7dd247 fix(setup): keep an API key variable name the user types
`reasonix setup` asked for the API key variable name, then saved every
new key under a freshly allocated REASONIX_CONNECTION_*_KEY slot, so a
typed name was dropped from config.toml and .env without a word.

A typed name is now kept when saving under it changes nothing another
reader resolves: no other provider, bot or remote-host setting reads it,
the credential store holds no value or tombstone for it, and the process
environment does not already set it (compared case-insensitively on
Windows). The wizard checks that as soon as the name is typed, says what
holds it, and asks again; Enter still means a private slot. The commit
repeats the check under the edit locks, so a name claimed in between
refuses the save with config.ErrCredentialKeyInUse instead of
overwriting it.

The staging journal records a keyed digest of each staged value, and
cleanup and crash recovery remove a slot only while the store still holds
that value, so a named slot another writer took over survives recovery.
Whether the config still references a slot is read from the parsed
config instead of a substring match, so API_KEY is no longer "referenced"
by DEEPSEEK_API_KEY and left behind as an orphan.

The prompt no longer shows a bracketed default it would not use.

Fixes #11002
2026-09-27 01:59:35 -07:00
xiexian a44319982f fix(sandbox): validate and narrow worktree metadata hints 2026-09-27 16:56:29 +08:00
YHH a8bea84e68 Merge pull request #10914 from esengine/fix/remote-prompt-card
fix(desktop): a remote Ask shows once and can be answered
2026-09-27 01:30:14 -07:00
YHH f457bbbd61 Merge pull request #10883 from esengine/fix/archive-recovered-copies
fix(desktop): archiving a recovered legacy conversation archives all its copies
2026-09-27 01:21:53 -07:00
YHH 3007c94084 Merge pull request #10997 from KHG420/codex/1x-history-reload-identity
fix(desktop): accept refreshed history after cache eviction
2026-09-27 01:12:16 -07:00
xiexian c09dbf56b9 fix(sandbox): include common Git metadata in retry hint 2026-09-27 16:08:19 +08:00
xiexian 26984605be fix(sandbox): guide worktree Git metadata write approval 2026-09-27 16:04:14 +08:00
esengine 85759d074c fix(desktop): archiving a recovered legacy conversation archives all its copies
A 1.38 write conflict saved one recovery copy per writer, each with the
recovered flag and the same parent in its metadata. The desktop catalog
runs metadata-only, which never folds them, so every copy is its own
sidebar row with the same title and the "legacy" and "recovered" badges.
Archiving a row archived exactly that copy and the next one took its
place, which reads as "cannot archive".

Archiving a recovered legacy source now also archives the other recovered
copies in its directory whose metadata names the same parent, each through
the same source archive path. Unrelated conversations are untouched, and
everything lands in the archive where it can be restored.

Reproduced on Windows with 1.39.1 and two copies written by
SaveConflictRecoveryBranch: one archive left the second row; with the fix
both rows go and both appear under Archived.
2026-09-27 00:59:53 -07:00
xiexian e47c32200c test(desktop): type history reload fixture for CI 2026-09-27 15:06:52 +08:00
xiexian 861a7b8d12 fix(desktop): accept refreshed older history after cache eviction 2026-09-27 14:47:40 +08:00
YHH 32107d7bb6 Merge pull request #10983 from esengine/fix/v1-release-notes-authors
feat(release-notes): credit pull request authors and issue fixers in rendered notes
2026-09-26 23:37:37 -07:00
esengine d5c9f2d739 feat(release-notes): credit pull request authors and issue fixers in rendered notes
Rendering now resolves every refs number through the GitHub GraphQL API.
A pull request gains "by @author". An issue gains "fixed in #M by @author"
for each merged pull request GitHub links as closing it (the ClosedEvent
closer and closedByPullRequestsReferences; mentions are not fixes); an
issue fixed by a direct push stays plain. Bot authors are never credited.
A number that names a discussion or nothing stays plain with a warning.

The closing Contributors / 贡献者 section lists exactly the credited
humans, once each, in first-appearance order. It replaces the old Thanks
section, which linked the catalog's contributors (github-actions[bot]
included) without mentioning them; that field no longer feeds the notes.

Lookups are cached per run, bounded in concurrency, and retried with
backoff on 5xx, rate limits and dropped connections. A failure that
persists, or an authorisation failure, renders the ref as its plain link
and fails the command with a release_credits.* code.

Because rendering now reads GitHub, the seal job renders the notes once
into the candidate payload (evidence/release-notes.md, recorded as
notes.renderedPath and bound to renderedSha256). Promotion publishes
those sealed bytes with no network call, so a rename, a newly linked fix
or an outage after sealing cannot change or block the release. Candidates
sealed before this carry no renderedPath and render offline as before.

Every job that renders, and every caller of a reusable workflow that
does, gains issues: read and pull-requests: read.
2026-09-26 23:24:50 -07:00
YHH 1a74c7450f Merge pull request #10979 from KHG420/codex/fix-1x-sidebar-loading
fix(desktop): show initial project loading state on 1.x
2026-09-26 22:59:20 -07:00
xiexian 947ce21896 chore(desktop): stay within project tree line budget 2026-09-27 13:30:40 +08:00
xiexian 872ad88677 fix(desktop): settle project shell loading after read failure 2026-09-27 13:21:07 +08:00
YHH 4512a84cbb Merge pull request #10977 from KHG420/codex/fix-reopened-session-index
fix(session): extend cached index after reopening writer
2026-09-26 22:17:13 -07:00
YHH 9424d402fb Merge pull request #10973 from esengine/fix/v1-10968-stream-generation
fix(transcript): stream live deltas for sessions on the legacy projection
2026-09-26 22:00:21 -07:00
esengine 2dfa1ca44c fix(transcript): stream live deltas for sessions on the legacy projection
A session opened from a legacy topic path follows its transcript through
the legacy projection (storageBackend "legacy"). There every turn event,
text and reasoning deltas included, takes the next ledger sequence, so
each follow change advanced commitSeq by one but carried no firstSeq.
The desktop follow client treats a change without firstSeq as a display
frame that must ride the current cut, rejected the very first change of
the turn as "transcript frame cut mismatch", dropped the subscription
and re-baselined once a second. No delta reached the reducer, so neither
thinking nor answer streamed and the reply appeared whole at turn end.

The stream_attempt commit on that path also asked for a reset, because
the legacy path never recorded a result sequence. Recording the message
event's sequence covers rounds with text or reasoning, but a tool-call-
only round and an interrupted round publish no message event at all.

Projection.Apply now publishes the sequence each envelope occupies as
the change's firstSeq. A commit settles on the recorded message sequence
or, when the round had no message event, on its own sequence: the agent
emits the commit only after the round's record is durably appended, so
that sequence is already covered. The entry is dropped at the commit so
the map stays bounded. ApplyFrame (the exclusive runtime path) is
unchanged.

Test: TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts
opens a legacy topic through StartTopicActivation on a real App with a
scripted streaming provider, follows through TranscriptFollowForTab and
applies the renderer's acceptance rules (mirroring
transcriptFollowClient.ts validate: coverage, sampling, resultKind,
records-based result identity with its 192 cap). Subtests run a
streamed reply, a tool-call-only round and a cancelled round, each
followed by a streamed reply.
- main-v2: all three fail with "frame_cut_mismatch at revision 3".
- message-sequence fix only: tool-call-only and cancelled rounds fail
  with "revision_gap" at the commit.
- this change: all pass, every delta delivered with no re-baseline.
Replaying captured follow responses through the real
TranscriptFollowClient accepted 0 deltas before and all of them after.

Fixes #10968
2026-09-26 21:32:08 -07:00
YHH e5795e5e0f chore: extract website and platform from product tree
Move the website and Cloudflare platform services to their dedicated protected branches. Keep main-v2 focused on the product and retain only the product-side release integration contract.
2026-09-27 04:29:09 +00:00
xiexian 86db9b4df3 fix(session): extend cached index after reopening writer 2026-09-27 12:22:14 +08:00
xiexian 8829515d5e style(desktop): keep project tree within lint budget 2026-09-27 12:19:16 +08:00
xiexian 8b2504fb6b fix(desktop): show loading state until first project tree snapshot 2026-09-27 12:04:12 +08:00
Adalberto Pereira 42503a4d36 fix(permission): ask unicode.IsSpace on both sides of the invariant / 检查与其不变量使用同一套空白判断
The check rejected " " and "\t" while the invariant test that justifies it
pinned " \t\r\n", so a tool name broken across lines was unmatchable but not
reported, and the two could drift further apart with neither failing. Both now
ask unicode.IsSpace, which also covers the non-breaking space a settings panel
can paste in.
2026-09-26 17:46:53 -03:00
Adalberto Pereira 598ecbf1b2 fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
A permission rule is "ToolName" or "ToolName(glob)", but the rules people
write are bare shell commands: deny = ["rm", "git reset", "git push --force"].
ParseRule treats only an empty tool name as malformed, so those entries parse,
install, and then match nothing — the author believes a deny guards them and it
never fires. ParseRule's own doc comment states the intent ("so the caller can
warn rather than silently install a rule that matches nothing"); no caller did.

UnmatchableRules reports a rule whose tool name contains whitespace and renders
the Bash(...) rewrite that expresses the intent. Whitespace is an exact test
rather than a guess: a tool name is an identifier and never contains any, which
is now pinned by TestNoBuiltinToolNameHasWhitespace. That keeps the warning off
a plugin or MCP tool that is simply not registered yet — the per-run registry is
still filling when the policy is built, so membership cannot be tested there
without accusing rules that work.

The notice goes to the event sink rather than Config.LoadWarnings because only
the desktop reads those, and a rule typed into config.toml has to reach a CLI
user too.
2026-09-26 17:40:45 -03:00
esengine d730d8c8f8 fix(desktop): a remote Ask shows once and can be answered
#10865: in an SSH remote workspace an Ask showed twice and neither card
answered. The remote surface's card was disabled with "This card's exact
request identity is unavailable", and the footer's copy looked live but
its Submit went nowhere.

- RemoteSessionSurface required a runtimeEpoch on every prompt. A CLI
  serve never binds desktop routing metadata, so its prompts carry none;
  the serve and the kernel fence on the epoch only when they have one,
  and the desktop bridge forwards it unchanged. The check now stops at
  the host, session, generation and turn it can actually expect.
- The shell's decision footer also docked the remote prompt from shared
  state, but its answer path targets a local session a remote tab does
  not have, so it was cancelled as not ready. On a remote tab the footer
  leaves approvals, asks, MCP interactions and extension forms to the
  remote surface; shell decisions (clear context, close, conflicts) stay.

Reproduced on Windows with 1.39.1 against a Linux container over SSH
(same two cards, same error); with this build one card appears, both
questions answer and the turn continues.
2026-09-26 10:38:56 -07:00
675 changed files with 21002 additions and 39374 deletions

No files matched your search

-2
View File
@@ -10,9 +10,7 @@ on:
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
+2 -33
View File
@@ -1636,16 +1636,13 @@ jobs:
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "macos-15-intel" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-candidate.yml \
.github/workflows/release-candidate-verify.yml \
.github/workflows/release-promote.yml \
.github/workflows/release-verify.yml \
.github/workflows/release-site-recovery.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/pages.yml \
.github/workflows/apple-notary-log.yml \
.github/workflows/macos-signing-check.yml \
.github/workflows/release-verify-issues.yml \
@@ -1669,14 +1666,14 @@ jobs:
node scripts/check-single-release-public-contract.mjs
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
# Required check for the root-module and site jobs the per-OS `test` legs do
# required checks for root-module jobs the per-OS `test` legs do
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
# always report a result: `success` here means "did not fail", not "validated
# this diff" — the same trade-off the required `test` legs already make. Do
# not add govulncheck: it sets continue-on-error, so needs.*.result is
# `success` even when its steps fail and the assertion would be a tautology.
root:
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
needs: [changes, windows-control, windows-isolated, sdk, coverage]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
@@ -1684,12 +1681,10 @@ jobs:
env:
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
CONTROL_RESULT: ${{ needs.windows-control.result }}
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
SDK_RESULT: ${{ needs.sdk.result }}
SITE_RESULT: ${{ needs.site.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
test "$CHANGES_RESULT" = success
@@ -1697,7 +1692,6 @@ jobs:
test "$CONTROL_RESULT" = success
test "$SDK_RESULT" = success
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
# frontend unit coverage can run once in desktop-frontend without weakening
@@ -1731,31 +1725,6 @@ jobs:
test "$FRONTEND_RESULT" = skipped
fi
# 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.
# The notes_only term matches every other gated job here. Without it a
# release-notes-only push runs this job while the root aggregate expects the
# house-style skip, which would leave main-v2 permanently red. A pure
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
site:
needs: changes
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .node-version
- name: test
run: npm test
govulncheck:
needs: changes
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
@@ -1,46 +0,0 @@
name: Deploy accounts worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
-168
View File
@@ -1,168 +0,0 @@
name: Deploy crash worker
on:
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Apply and verify diagnostics v2 D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Apply dashboard performance indexes
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:dashboard-indexes
- name: Apply registry performance indexes and install rollup
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:registry-indexes
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
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
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
echo "Firebase crash data migration must run from main-v2."
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
-36
View File
@@ -1,36 +0,0 @@
name: Deploy forum worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
npx wrangler deploy
-76
View File
@@ -1,76 +0,0 @@
name: Deploy site
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
on:
workflow_dispatch:
inputs:
release_version:
description: "Optional release version that owns this deployment"
required: false
default: ""
type: string
release_request:
description: "Release workflow correlation ID (optional)"
required: false
default: ""
type: string
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: inputs.release_version != ''
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
if: inputs.release_version != ''
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: inputs.release_version != ''
with:
go-version-file: go.mod
cache: false
- name: Recheck release ownership immediately before deployment
if: inputs.release_version != ''
env:
VERSION: ${{ inputs.release_version }}
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
- id: deploy
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
-106
View File
@@ -1,106 +0,0 @@
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
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 [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
source_sha="$(git merge-base origin/main-v2 HEAD)"
else
git checkout -b "$branch"
source_sha="$(git rev-parse HEAD)"
fi
git merge-base --is-ancestor "$source_sha" origin/main-v2
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$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 }}
run: |
set -euo pipefail
# The first preparation fixes the product range. Reruns update the
# draft without silently absorbing unrelated main-v2 merges.
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
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 ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
+18 -8
View File
@@ -194,6 +194,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -210,6 +212,7 @@ jobs:
env:
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
run: |
node scripts/release-notes.mjs validate
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
@@ -259,6 +262,8 @@ jobs:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
@@ -285,6 +290,8 @@ jobs:
attestations: write
contents: read
id-token: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -338,6 +345,16 @@ jobs:
test -s payload/evidence/windows-amd64.json
test -s payload/evidence/windows-arm64.json
test -s payload/evidence/macos-universal-intel.json
# Rendered once and sealed: promotion publishes these bytes and never
# reads GitHub again, so a later rename or outage cannot change them.
- name: Render reviewed notes into the sealed payload
env:
GH_TOKEN: ${{ github.token }}
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
git worktree remove "$worktree"
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: payload/**/*
@@ -359,13 +376,6 @@ jobs:
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Render reviewed notes from the immutable product
run: |
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
git worktree remove "$worktree"
- name: Seal candidate record
env:
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
@@ -385,7 +395,7 @@ jobs:
RELEASE_ACCEPTANCE_JSON: >-
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
run: |
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
+4 -9
View File
@@ -1084,6 +1084,8 @@ jobs:
permissions:
contents: write
actions: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# Approved orchestrators have already passed the matching GitHub environment.
# Direct prereleases and manual Stable recovery pass release-gate above. The
# Certum signing completes before either platform bundle reaches publication.
@@ -1200,6 +1202,8 @@ jobs:
# GitHub release as usual.
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
- name: Revalidate approved release ref
@@ -1292,7 +1296,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
# GitHub release, so it must fail closed before attempting publication.
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
@@ -1818,11 +1821,3 @@ jobs:
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+2 -9
View File
@@ -4,16 +4,9 @@ on:
pull_request:
paths:
- "release-notes/**"
- "scripts/generate-release-notes.mjs"
- "scripts/release-notes*.mjs"
- "scripts/release-credits*.mjs"
- "scripts/release-event.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "site/src/data/releases.ts"
- "site/src/styles/changelog.css"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
@@ -27,4 +20,4 @@ jobs:
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
+16 -8
View File
@@ -23,6 +23,8 @@ permissions:
actions: write
attestations: read
contents: write
issues: read # granted to the nested release workflows, which can render notes
pull-requests: read
jobs:
preflight:
@@ -146,12 +148,19 @@ jobs:
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Render and bind reviewed release notes
# A candidate sealed with its notes publishes those bytes without touching
# the network. One sealed before that renders offline from its own source.
- name: Bind reviewed release notes
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
if [ -n "$sealed" ]; then
cp "payload/$sealed" release-notes.md
else
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
fi
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
@@ -306,7 +315,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher
@@ -326,7 +334,7 @@ jobs:
with:
go-version-file: go.mod
cache: false
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
- name: Reconcile public artifacts, release event, and durable ledger
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
@@ -334,7 +342,7 @@ jobs:
RELEASE_OPERATION: ${{ inputs.operation }}
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
run: bash scripts/sync-release-site.sh
run: bash scripts/reconcile-release-publication.sh
- name: Upload publication ledger
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
-111
View File
@@ -1,111 +0,0 @@
name: Recover release site
run-name: Recover site v${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Already-published stable version; no builds or package publication"
required: true
type: string
concurrency:
group: stable-release-publication
cancel-in-progress: false
permissions:
contents: read
jobs:
preflight:
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.identity.outputs.source_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Validate executable contracts and public access before approval
env:
VERSION: ${{ inputs.version }}
run: |
bash scripts/validate-release-control-plane.sh
bash scripts/check-release-public-access.sh "$VERSION"
- name: Bind existing public tags to protected history
id: identity
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
git merge-base --is-ancestor "$source_sha" origin/main-v2
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
- name: Re-read public package identities before approval
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
run: bash scripts/verify-stable-release-artifacts.sh
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/site-recovery-preflight.json
retention-days: 90
if-no-files-found: error
sync:
needs: preflight
environment: release
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
actions: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Recover only the website and verify public state again
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_SITE_RECOVERY_ONLY: "true"
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
run: bash scripts/sync-release-site.sh
- name: Preserve successful or incomplete recovery evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/publication-ledger.json
retention-days: 90
if-no-files-found: error
+6 -5
View File
@@ -55,6 +55,8 @@ concurrency:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
jobs:
preflight:
@@ -63,6 +65,8 @@ jobs:
permissions:
actions: read
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
@@ -111,6 +115,8 @@ jobs:
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
env:
GH_TOKEN: ${{ github.token }}
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
@@ -226,7 +232,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher to succeed
@@ -303,7 +308,3 @@ jobs:
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
+1 -1
View File
@@ -35,5 +35,5 @@ jobs:
RELEASE_OPERATION: recover
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
VERIFY_HOMEPAGE: "true"
VERIFY_PUBLIC_POINTERS: "true"
run: bash scripts/verify-stable-release-artifacts.sh
+5
View File
@@ -212,6 +212,10 @@ jobs:
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# The Stable caller has already passed the single GitHub release approval.
# Official standalone recovery passes release-gate above. This job therefore
# must not add a second GitHub environment approval.
@@ -274,6 +278,7 @@ jobs:
if: ${{ !inputs.orchestrated }}
env:
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
env:
+53 -6
View File
@@ -56,9 +56,9 @@ Supported boundaries include:
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- HTTP `serve` protections, including the launch token every state-changing
request requires when authentication is off, localhost binding assumptions,
JSON-only state-changing requests, and CORS restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
- Project configuration files intentionally loaded from the current workspace.
- The user's own configuration under the Reasonix home directory.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- MCP servers, language servers, hooks, and slash commands installed, enabled,
or approved by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## Project Configuration
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
repository is not a decision to trust its author, so these files may narrow
what the user configured and never widen it.
| Setting | What a workspace file may do |
| --- | --- |
| `sandbox.bash` | Move `off` to `enforce`, never back |
| `sandbox.network` | Move `true` to `false`, never back |
| `sandbox.forbid_read` | Add to the user's list |
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
| `desktop.default_tool_approval_mode` | Nothing |
| `[network]` proxy settings, `[bot]` | Nothing |
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
An "always allow" answer, or extra write access, granted in a workspace is
stored under the Reasonix home, not in the workspace.
Programs a workspace names for the host to run stay off until the user
approves them for that workspace:
- Hooks, language server commands, `tools.search.rg_path`,
`tools.shell.path` and `[browser]` launch settings are approved with
`reasonix trust`, or for hooks from the desktop hooks settings.
- An approval is stored under the Reasonix home with a digest of the
declaration and of the workspace files it names; any change needs approval
again. Approvals and grants belong to one workspace folder.
- Hooks and language servers are checked against their approval again before
each start, and refused if a file they name changed, appeared or now
resolves elsewhere.
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
`browser.chrome_path`) may not point into the workspace, an `allow_write`
root, a temporary directory or a toolchain cache the bash jail leaves
writable, and may not climb with `..`; its approval covers the program
file's content.
- Without an interactive frontend they stay off.
- MCP servers a workspace declares stay off until the user enables that
declaration for that workspace.
A project value that is not applied is reported as a load warning with the
reason. A way for workspace files to widen any of the above without the
user's approval is a security issue.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
+18 -8
View File
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
}
}
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
if err != nil {
return fmt.Errorf("migrate shell: %w", err)
if err != nil || len(shellMembers) == 0 {
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
}
for _, member := range shellMembers {
members = append(members, member)
@@ -431,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
return nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
// readLegacyPendingUpdate returns the transaction an old file updater left for
// this install and version, or nil when there is none.
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
tx, err := repair.ReadPendingUpdate()
if os.IsNotExist(err) {
return nil
return nil, nil
}
if err != nil {
return fmt.Errorf("migrate: read legacy pending update: %w", err)
return nil, fmt.Errorf("migrate: read legacy pending update: %w", err)
}
if tx.TargetKind != "file" {
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
return nil, fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
}
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
return nil, fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
}
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
return fmt.Errorf("migrate: pending update target escapes install root")
return nil, fmt.Errorf("migrate: pending update target escapes install root")
}
}
return tx, nil
}
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
if err != nil || tx == nil {
return err
}
id := repair.UpdateTransactionID(tx)
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
@@ -11,6 +11,10 @@ import (
)
func TestMain(m *testing.M) {
// A legacy-updater child keeps the home its parent test chose.
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
os.Exit(runLegacyChild(mode))
}
testenv.RunWithIsolatedUserState(m)
}
@@ -69,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
func TestMigrateFlatInstallToVersioned(t *testing.T) {
root := t.TempDir()
writeFlatUnit(t, root, "flat")
writeShellTree(t, root, "flat")
// Thin launcher entry must already exist (packaging places it). Use a
// non-executable marker so startLauncher fails closed without hanging.
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
@@ -0,0 +1,102 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// errShellMissing identifies a flat release unit whose desktop cannot start:
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
// updaters publish only the three binaries they know by name.
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
const migrateLogName = "legacy-migrate.log"
// refuseShellMissing commits nothing for an absent or incomplete shell: no
// version directory, no current.json, no health mark and no flat cleanup.
// Where an old updater's transaction is still pending it restores the release
// unit that transaction replaced.
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
if cause != nil {
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
}
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
switch {
case rollbackErr != nil:
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
case result.RolledBack:
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
if relaunch {
if err := startRestoredRelease(installRoot); err != nil {
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
}
}
}
logMigrateRefusal(installRoot, result.RolledBack, refusal)
return refusal
}
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
// Windows cannot replace the running migrator image, and its installer
// carries the shell into update mode, so this state is Unix-only.
if runtime.GOOS == "windows" {
return repair.UpdateRollbackResult{}, nil
}
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
if err != nil || tx == nil {
return repair.UpdateRollbackResult{}, err
}
result, err := repair.RollbackPendingUpdateExact(tx)
if err == nil && result.MixedInstall {
err = fmt.Errorf("rollback left a mix of two releases")
}
return result, err
}
// startRestoredRelease relaunches the way the legacy updater itself does:
// through its Guard when the restored unit has one, else the desktop directly.
func startRestoredRelease(installRoot string) error {
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
return startDetached(installRoot, guard, "launch", "--detach")
}
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
return startDetached(installRoot, desktop)
}
return fmt.Errorf("restored release has no entry point")
}
func startDetached(dir, path string, args ...string) error {
cmd := exec.Command(path, args...)
cmd.Dir = dir
return cmd.Start()
}
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
// updater has no terminal, so stderr alone reaches nobody.
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
home := config.ReasonixHomeDir()
if home == "" {
return
}
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
return
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer f.Close()
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
}
@@ -0,0 +1,290 @@
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
const (
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
legacyFromVersion = "v1.19.2"
legacyToVersion = "v1.39.4"
childShellMissing = 3
)
func writeShellTree(t *testing.T, root, label string) {
t.Helper()
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
t.Fatal(err)
}
}
}
func snapshotFlat(t *testing.T, root string) map[string]string {
t.Helper()
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, entry := range entries {
if !entry.Type().IsRegular() {
out[entry.Name()] = "<dir>"
continue
}
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
if err != nil {
t.Fatal(err)
}
out[entry.Name()] = string(data)
}
return out
}
func assertNothingCommitted(t *testing.T, root string) {
t.Helper()
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
t.Fatalf("current.json written for a release unit without a shell: %v", err)
}
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
}
}
func assertRefusalLogged(t *testing.T, want ...string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
if err != nil {
t.Fatalf("refusal was not logged: %v", err)
}
for _, w := range append([]string{"code=shell_missing"}, want...) {
if !strings.Contains(string(data), w) {
t.Fatalf("refusal log %q lacks %q", data, w)
}
}
}
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
cases := map[string]func(t *testing.T, root string){
"absent": func(*testing.T, string) {},
"incomplete": func(t *testing.T, root string) {
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
t.Fatal(err)
}
},
}
for name, shell := range cases {
t.Run(name, func(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
writeFlatUnit(t, root, "new")
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
t.Fatal(err)
}
shell(t, root)
before := snapshotFlat(t, root)
err := migrateWithRelaunch(root, legacyToVersion, false)
if !errors.Is(err, errShellMissing) {
t.Fatalf("migrate = %v, want errShellMissing", err)
}
assertNothingCommitted(t, root)
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
}
assertRefusalLogged(t, "rolled_back=false")
})
}
}
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
// runs the updater's own transaction steps from inside the install, publishing
// only desktop, CLI and the migrator under the Guard name.
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the file-by-file legacy updater is the Unix portable path")
}
t.Setenv("REASONIX_HOME", t.TempDir())
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
oldDesktop, err = os.ReadFile(self)
if err != nil {
t.Fatal(err)
}
root = t.TempDir()
for name, body := range map[string][]byte{
"reasonix-desktop": oldDesktop,
"reasonix-guard": []byte("old-guard"),
"reasonix": []byte("old-cli"),
} {
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
t.Fatal(err)
}
}
if withShell {
writeShellTree(t, root, "new")
}
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
t.Fatalf("legacy updater child exited %d: %s", code, out)
}
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
}
return root, oldDesktop
}
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
t.Helper()
cmd := exec.Command(exe, "-test.run=^$")
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
var exit *exec.ExitError
if errors.As(err, &exit) {
return exit.ExitCode(), out.String()
}
if err != nil {
t.Fatalf("run %s child: %v", mode, err)
}
return 0, out.String()
}
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
root, oldDesktop := legacyUpdatedInstall(t, false)
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
if code != childShellMissing {
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
}
assertNothingCommitted(t, root)
for name, want := range map[string][]byte{
"reasonix-desktop": oldDesktop,
"reasonix-guard": []byte("old-guard"),
"reasonix": []byte("old-cli"),
} {
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
}
}
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
}
if repair.PendingUpdateExists() {
t.Fatal("legacy transaction still pending after rollback")
}
assertRefusalLogged(t, "rolled_back=true")
}
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
root, _ := legacyUpdatedInstall(t, true)
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
t.Fatalf("migrator child exited %d: %s", code, out)
}
ptr, err := installlayout.ReadCurrent(root)
if err != nil || ptr.ActiveVersion != legacyToVersion {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(root)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
t.Fatalf("active desktop = %q", got)
}
if !installlayout.HasActiveShell(root) {
t.Fatal("active version lost its shell")
}
if repair.PendingUpdateExists() {
t.Fatal("legacy transaction was not committed")
}
}
func runLegacyChild(mode string) int {
root := os.Getenv(legacyChildRootEnv)
var err error
switch mode {
case "legacy-update":
err = applyLegacyUpdate(root)
case "migrate":
err = migrateWithRelaunch(root, legacyToVersion, false)
if errors.Is(err, errShellMissing) {
fmt.Fprintln(os.Stderr, err)
return childShellMissing
}
default:
err = fmt.Errorf("unknown child mode %q", mode)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
// for step, with the running desktop as the updater.
func applyLegacyUpdate(root string) error {
exe, err := os.Executable()
if err != nil {
return err
}
migrator, err := os.ReadFile(exe)
if err != nil {
return err
}
desktop := filepath.Join(root, "reasonix-desktop")
guard := filepath.Join(root, "reasonix-guard")
cli := filepath.Join(root, "reasonix")
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
if err != nil {
return fmt.Errorf("prepare: %w", err)
}
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
if err != nil {
return fmt.Errorf("claim: %w", err)
}
defer release()
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
return err
}
var receipts []repair.FileUpdateInstallReceipt
for _, member := range []struct {
path string
body []byte
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
if err != nil {
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
}
receipts = append(receipts, receipt)
}
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
return fmt.Errorf("record installed: %w", err)
}
return repair.ClearUpdateApplyFailureExact(claimed)
}
+16 -11
View File
@@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() {
if len(legacy) == 0 {
return
}
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
_ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
seen := make(map[string]bool, len(f.Projects)+len(legacy))
for _, p := range f.Projects {
seen[p.Root] = true
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
}
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
if a.isRemoteTab(tabID) {
used, window, ok := a.remoteContextSnapshot(tabID)
if ok {
return ContextInfo{Used: used, Window: window}
}
return ContextInfo{}
}
read := a.captureContextRead(tabID)
ctrl := read.ctrl
var info ContextInfo
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
}
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
if err != nil {
return p.ShouldAutoStart()
}
return enabled
return config.MCPServerEnabled(p, workspace)
}
func mcpRuntimeState(status string) string {
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
if !found {
return fmt.Errorf("no configured MCP server named %q", name)
}
if err := config.RecordExplicitStart(entry, root); err != nil {
return err
}
controllers := a.mcpControllersSharingHost(host, name, ctrl)
for i := range controllers {
if controllers[i].ctrl == ctrl {
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
}
a.bumpExtensionGeneration()
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
if err := config.RecordExplicitStart(updated, root); err != nil {
return err
}
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
recordMCPFailure(ctrl, updated, err)
return nil
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
return err
}
_, err := config.UpsertPluginInSourceForRoot(root, entry)
_, err := config.UpsertPluginKeepingDecision(root, entry)
return err
}
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
return "", ""
}
func (a *App) activeWorkspaceBase() (string, error) {
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
}
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
tabID = strings.TrimSpace(tabID)
a.mu.RLock()
+42 -71
View File
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
approveWorkspace(t, project)
userCfg := config.LoadForEdit(config.UserConfigPath())
if err := userCfg.SetDesktopLanguage("en"); err != nil {
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
}
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
// With no user config yet, Settings start from the defaults, not from the
// checkout's reasonix.toml, and the first edit writes only the user's choice.
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
default_model = "legacy-provider/legacy-model"
[desktop]
language = "zh"
layout_style = "workbench"
theme = "light"
theme_style = "glacier"
close_behavior = "quit"
status_bar_style = "text"
status_bar_items = ["model", "cache", "balance"]
`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
approveWorkspace(t, project)
t.Chdir(project)
app := NewApp()
got := app.Settings()
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
}
if err := app.SetDesktopLanguage("en"); err != nil {
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
}
}
@@ -3227,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
approveWorkspace(t, projectRoot)
app := NewApp()
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
@@ -3954,6 +3928,7 @@ default_effort = "max"
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, projectA)
staleConfig := `default_model = "stale/stale-model"
[[providers]]
name = "stale"
@@ -3966,6 +3941,7 @@ reasoning_protocol = "none"
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, projectB)
topicID := "topic_effort_owner"
topicTitle := "Effort owner"
@@ -6909,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
return nil
}
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
}
return
}
}
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
isolateDesktopUserDirs(t)
reasonixHome := config.ReasonixHomeDir()
@@ -7006,6 +6949,8 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -7093,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -7116,6 +7062,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -7164,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
host := plugin.NewHost()
defer host.Close()
@@ -7202,6 +7150,7 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -7352,6 +7301,7 @@ url = %q
`, projectServer.URL), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
host := plugin.NewHost()
t.Cleanup(host.Close)
@@ -7505,6 +7455,7 @@ network = true
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
entry := config.PluginEntry{
Name: "h", Command: exe, Args: helperArgs,
@@ -8323,6 +8274,7 @@ args = ["serve"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8434,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8961,6 +8915,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8992,6 +8948,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9022,6 +8979,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9056,6 +9015,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9115,6 +9076,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9192,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9230,6 +9193,8 @@ tier = "background"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9280,6 +9245,8 @@ name = "codegraph"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
reg := tool.NewRegistry()
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
@@ -9338,6 +9305,8 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9431,6 +9400,8 @@ tier = "eager"
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, dir)
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+30 -155
View File
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
}
}
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
// A checkout's [bot] never reaches the bot runtime or the user's config, with
// or without a user config of its own.
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
for _, withUser := range []bool{false, true} {
isolateDesktopUserDirs(t)
if withUser {
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() != "dark" {
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
t.Fatalf("write project config: %v", err)
}
t.Chdir(project)
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
}
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
}
}
}
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
close_behavior = "quit"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
settings := NewApp().Settings()
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
}
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
}
}
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
+10 -2
View File
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
// optional Git isolation path. A false result never disables writing itself;
// the cross-platform workspace writer lease remains the no-Git fallback.
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
repo, err := a.workspaceRepoForRoot(workspaceRoot)
if err != nil {
return worktree.Availability{Reason: err.Error()}
}
return inspectDeliveryWorktree(a.bootContext(), repo)
}
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
return worktree.Result{}, err
}
defer releaseAdmission()
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
repo, err := a.workspaceRepoForRoot(workspaceRoot)
if err != nil {
return worktree.Result{}, err
}
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
}()
if err != nil {
return IsolatedWorktreeOpenResult{}, err
+5 -2
View File
@@ -7,13 +7,15 @@ import (
"testing"
"reasonix/internal/config"
"reasonix/internal/gitcmd"
"reasonix/internal/worktree"
)
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
original := inspectDeliveryWorktree
t.Cleanup(func() { inspectDeliveryWorktree = original })
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := filepath.Base(repo.Dir)
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
}
got := NewApp().DeliveryWorktreeAvailability("project")
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
original := createDeliveryWorktree
t.Cleanup(func() { createDeliveryWorktree = original })
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
source := filepath.Base(sourceRepo.Dir)
if source != "source-project" {
t.Fatalf("source = %q", source)
}
+11 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
@@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) {
// updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the
// desktop-projects cross-process file lock.
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error {
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error {
f := loadProjectsFile()
previous := make([]string, 0, len(f.Projects))
for _, project := range f.Projects {
previous = append(previous, project.Root)
}
changed, err := mutator(&f)
if err != nil {
return err
@@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo
if !changed {
return nil
}
if assignCollisions {
if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil {
return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err)
}
}
return saveProjectsFile(f)
}
+6 -3
View File
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
result.FallbackToShared = true
} else {
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
if !avail.Available {
result.FallbackToShared = true
} else if avail.SourceDirty {
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
return worktree.Result{}, err
}
defer releaseAdmission()
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
}()
if createErr != nil {
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
@@ -280,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator
if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
}
a.registerProjectRoot(workspaceRoot)
if err := a.registerProjectRoot(workspaceRoot); err != nil {
return forkedSessionTabOpen{}, err
}
}
if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
(*hook)()
+16 -10
View File
@@ -8,6 +8,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/gitcmd"
"reasonix/internal/worktree"
)
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
rollbackDeliveryWorktree = origRollback
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
}
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
source := sourceRepo.Dir
return worktree.Result{
WorkspaceRoot: isolatedRoot,
WorktreeRoot: filepath.Dir(isolatedRoot),
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
inspectDeliveryWorktree = origInspect
createDeliveryWorktree = origCreate
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
}
createCalls := 0
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
createCalls++
return worktree.Result{}, nil
}
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
inspectDeliveryWorktree = origInspect
createDeliveryWorktree = origCreate
})
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
return worktree.Availability{Reason: "not a repository"}
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
t.Fatal("fallback must not create a worktree")
return worktree.Result{}, nil
}
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
createDeliveryWorktree = origCreate
rollbackDeliveryWorktree = origRollback
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root}
}
created := worktree.Result{
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
Branch: "reasonix/delivery-test",
Head: "deadbeef",
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
return created, nil
}
rollbackCalls := 0
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
rollbackDeliveryWorktree = origRollback
forkTabBeforePublishHookForTest.Store(nil)
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root}
}
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
Branch: "reasonix/delivery-preserved",
Head: "deadbeef",
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
return created, nil
}
rollbackCalls := 0
+5 -5
View File
@@ -32,7 +32,7 @@
"test:code-browser": "node bench/code-theme.mjs",
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
"test:session-export-browser": "node bench/session-export.mjs",
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
@@ -72,14 +72,14 @@
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@modelcontextprotocol/ext-apps": "1.7.5",
"@tanstack/react-virtual": "^3.14.10",
"@tanstack/react-virtual": "^3.14.13",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"anser": "^2.3.5",
"hast-util-to-jsx-runtime": "2.3.6",
"highlight.js": "^11.12.0",
"html-url-attributes": "3.0.1",
"katex": "^0.18.6",
"katex": "^0.18.9",
"lucide-react": "^1.41.0",
"mermaid": "^11.17.2",
"qrcode.react": "^4.2.0",
@@ -103,7 +103,7 @@
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@typescript-eslint/parser": "^8.69.0",
"@typescript-eslint/parser": "^8.70.1",
"@vitejs/plugin-react": "^6.1.1",
"decode-named-character-reference": "^1.3.0",
"eslint": "^10.10.0",
@@ -113,7 +113,7 @@
"mdast-util-math": "^3.0.0",
"playwright": "^1.63.0",
"terser": "^5.51.2",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"vite": "^8.2.2"
},
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
assert.equal(view.context.items, remote.transcript.items);
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
assert.equal(view.composer.modelLabel, "remote-model");
assert.equal(view.composer.cwd, "remote-cwd");
assert.equal(view.composer.turnTokens, 7);
@@ -0,0 +1,13 @@
// Run: tsx src/__tests__/decision-surface-remote.test.ts
import assert from "node:assert/strict";
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
console.log("decision-surface-remote: 4 passed");
@@ -16,6 +16,7 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
const translate: Translator = key => dict[key];
for (const [error, key] of [
["dial tcp: connection refused", "error.connection"],
[{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"],
["lookup host: no such host", "error.dns"],
["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"],
["HTTP 429: too many requests", "error.rateLimit"],
@@ -36,6 +37,10 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
] as const) {
assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`);
}
assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), {
summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR",
});
assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence");
if (locale !== "en") {
for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) {
assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw });
@@ -59,10 +64,11 @@ await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]);
let changeLocale: (locale: Locale) => void;
let toast: ReturnType<typeof useToast>["showToast"];
const raw = "unknown library failure <img src=x onerror=alert(1)>";
const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>";
function Probe() {
changeLocale = useI18n().setPref;
toast = useToast().showToast;
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
}
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>));
@@ -76,9 +82,14 @@ assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw);
assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup");
await act(async () => changeLocale("zh-TW"));
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]);
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]);
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics");
await act(async () => changeLocale("en"));
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]);
await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click());
assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`);
assert.equal(document.querySelector("#transport-error img"), null);
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale");
await act(async () => changeLocale("zh-TW"));
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { getTranscriptStore } from "../lib/transcriptStore";
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
import { initialState, reducer, type State } from "../lib/useController";
const dom = new JSDOM("", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
let revision = 1;
const stub = installDesktopHostStub({
SessionHistoryWindowForTab: async () => ({
status: "ready",
snapshotSequence: revision,
generation: `generation-${revision}`,
totalTurns: 30,
hasOlder: true,
olderCursor: "older-cursor",
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
}),
});
try {
const store = getTranscriptStore();
for (const protocol of [1, 2] as const) {
revision = 1;
const tabId = `history-reload-tab-${protocol}`;
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
const initial = await store.loadLatest(tabId, sessionPath);
assert.equal(initial?.revision, 1);
store.evictTab(tabId);
revision = 2;
let state: State = {
...initialState,
transcriptProtocol: protocol,
historyStartTurn: initial!.startTurn,
historyTotalTurns: initial!.totalTurns,
historyHasOlder: true,
historyHasNewer: false,
historyOlderLoading: false,
historyNewerLoading: false,
historyRevision: 1,
historyDigest: "generation-1",
running: false,
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
sessionRevision: 1, sessionDigest: "generation-1" },
};
const actions: HistoryWindowAction[] = [];
const outcome = await loadHistoryWindow({
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
isCurrent: () => true,
currentState: () => state,
dispatch: action => {
actions.push(action);
state = reducer(state, action);
},
});
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
assert.equal(state.historyRevision, 2);
assert.equal(state.historyDigest, "generation-2");
assert.equal(state.historyOlderLoading, false);
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
}
} finally {
stub.uninstall();
dom.window.close();
}
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
ok(
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
"App decision surface recomputes when an MCP interaction arrives",
);
@@ -0,0 +1,125 @@
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
import { ToastProvider } from "../lib/toast";
import { LocaleProvider } from "../lib/i18n";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
if (value) passed += 1; else failed += 1;
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
return dom;
}
function topic(id: string): ProjectNode {
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
}
const inside = topic("inside");
const sibling = topic("sibling");
const outside = topic("outside");
const loose = topic("loose");
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, bindings });
const loaded = organization.groupsFor(folder).length > 0;
return <>
<output id="loaded">{loaded ? "yes" : "no"}</output>
{folder.children!.map((node) => {
const row = organization.topicRow(node, false);
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
})}
</>;
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function fireDrag(type: string, target: Element, data: Map<string, string>) {
const event = new window.Event(type, { bubbles: true, cancelable: true });
const dataTransfer = {
setData: (format: string, value: string) => { data.set(format, value); },
getData: (format: string) => data.get(format) ?? "",
effectAllowed: "all",
dropEffect: "none",
};
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
Object.defineProperty(event, "clientY", { value: 10 });
target.dispatchEvent(event);
}
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
const dom = installDom();
const mutations: SessionOrganizationMutation[] = [];
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
const bindings: ProjectTreeOrganizationBindings = {
ReorderTopics: async () => {},
ListProjectGroups: async () => [],
SaveSessionGroups: async () => {},
GetSessionOrganization: async () => snapshot(),
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
};
const root = createRoot(document.getElementById("root")!);
await act(async () => {
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
await flush();
});
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
const data = new Map<string, string>();
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
await act(async () => root.unmount());
dom.window.close();
return mutations;
}
console.log("\nproject tree drop onto a group member row");
{
const mutations = await dropOnto("outside", "inside");
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
}
{
const mutations = await dropOnto("sibling", "inside");
ok(mutations.length === 1 && mutations[0].kind === "move",
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
}
{
const mutations = await dropOnto("outside", "loose");
ok(mutations.length === 1 && mutations[0].kind === "move",
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
}
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
let reject!: (reason: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
@@ -78,9 +79,21 @@ try {
try {
await render(0);
assert.equal(pending.length, 1, "mount starts one shell read");
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
"an unanswered shell read must say it is loading");
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"pending shell read must not claim there are no projects");
if (scenario.name === "addition") {
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
"fast shell reads must not flash a skeleton");
await act(async () => mock.timers.tick(250));
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
"a delayed shell read shows a skeleton");
}
await act(async () => pending[0].resolve(snapshot(scenario.before)));
await flush();
assert.deepEqual(labels(), scenario.before.map(item => item.label));
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
// A read started before the mutation retains the old shell contents.
// Its scalar revision may equal the post-mutation read: this fixture
@@ -114,6 +127,44 @@ try {
await act(async () => root.unmount());
}
}
pending.length = 0;
localStorage.clear();
const emptyRoot = createRoot(container);
try {
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
await act(async () => pending[0].resolve(snapshot([])));
await flush();
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"an empty answer restores the true empty state");
} finally {
await act(async () => emptyRoot.unmount());
}
pending.length = 0;
localStorage.clear();
const failedRoot = createRoot(container);
try {
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
await flush();
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
"a failed first shell read must settle the loading state");
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
"the empty-state project action must remain available after a failed read");
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
"the empty-state remote action must remain available after a failed read");
} finally {
await act(async () => failedRoot.unmount());
}
} finally {
mock.timers.reset();
dom.window.close();
@@ -2,6 +2,11 @@
import assert from "node:assert/strict";
import { initialState, reducer } from "../lib/useController";
import { historyMessagesToItems } from "../lib/historyItems";
import { historyNoticeItems } from "../lib/controllerNotices";
import { presentError } from "../lib/errorPresentation";
import { en } from "../locales/en";
import type { HistoryMessage } from "../lib/types";
const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
const failed = reducer(started, {
@@ -25,3 +30,17 @@ assert.equal(
"Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
"stable provider id and sanitized path stay in live diagnostic details",
);
const diagnostic = { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" };
const history: HistoryMessage = { role: "notice", content: "opaque provider failure", level: "warn", diagnostic };
const live = reducer(started, { type: "event", e: { kind: "turn_done", err: history.content, diagnostic } });
const interrupted = reducer(started, { type: "event", e: { kind: "turn_done", status: "interrupted", err: history.content, diagnostic } });
const liveNotice = reducer(started, { type: "event", e: { kind: "notice", level: "warn", text: history.content, diagnostic } });
for (const items of [live.items, interrupted.items, liveNotice.items, historyMessagesToItems([history], "full").items, historyNoticeItems(history, "windowed")]) {
const item = items.find(item => item.kind === "notice" && item.level === "warn");
assert.ok(item?.kind === "notice");
assert.deepEqual(item.diagnostic, diagnostic, "live and both history projections retain structured evidence");
const presentation = presentError(item.text, key => en[key], "en", item.diagnostic);
assert.equal(presentation.summary, en["error.transportProtocol"]);
assert.match(presentation.detail, /PROTOCOL_ERROR/);
}
@@ -391,6 +391,22 @@ await act(async () => {
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
}
// A CLI serve never binds desktop routing metadata, so its prompts carry no
// runtimeEpoch; the serve fences on the epoch only when it has one.
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
await flush();
});
{
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
await flush();
});
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
await flush();
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
import { t } from "../lib/i18n";
const stored = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem(key: string) { return stored.get(key) ?? null; },
setItem(key: string, value: string) { stored.set(key, value); },
removeItem(key: string) { stored.delete(key); },
clear() { stored.clear(); },
},
});
let contexts = 0;
class FakeAudioContext {
destination = {};
constructor() { contexts++; }
createOscillator() { return { type: "sine", frequency: { setValueAtTime() {} }, connect() {}, start() {}, stop() {} }; }
createGain() {
return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} };
}
close() {}
}
Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
const owner = createRuntimeNotifications(() => ({ activeTabId: "A", t, showToast() {} }));
function chimesFor(event: { err?: string; outcome?: string }, only: "success" | "attention"): number {
setSuccessPreference(only === "success" ? "synth" : "off");
setAttentionPreference(only === "attention" ? "synth" : "off");
const before = contexts;
owner.accept({ event: { kind: "turn_done", tabId: "A", ...event } });
return contexts - before;
}
const readiness = { err: "delivery checks are incomplete", outcome: "final_readiness" };
assert.equal(chimesFor(readiness, "attention"), 1, "a turn that stopped for delivery checks must alert the user");
assert.equal(chimesFor(readiness, "success"), 0, "a turn that stopped for delivery checks is not a success");
for (const outcome of ["recovery_paused", "incomplete_read", "completion_uncertain"]) {
assert.equal(chimesFor({ err: "paused", outcome }, "attention"), 1, `${outcome} waits on the user`);
}
assert.equal(chimesFor({}, "success"), 1, "a clean finish keeps the success chime");
assert.equal(chimesFor({}, "attention"), 0, "a clean finish does not ring the attention chime");
assert.equal(chimesFor({ err: "provider failed" }, "success") + chimesFor({ err: "provider failed" }, "attention"), 0,
"an ordinary failure stays silent");
owner.dispose();
console.log("runtime notifications: paused turn outcomes ring the attention chime");
@@ -35,7 +35,9 @@ try {
assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className),
["topicbar__brand", "btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"],
"the Windows bar leads with the app mark");
assert.equal(document.querySelector(".topicbar__brand-logo")!.tagName, "IMG", "the leading mark is the app logo asset");
const logo = document.querySelector(".topicbar__brand-logo")!;
assert.equal(logo.getAttribute("role"), "img", "the leading mark is the app logo");
assert.equal(logo.getAttribute("aria-label"), "Reasonix");
await paint();
const action = document.querySelector(".topicbar__actions button");
assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1);
@@ -87,7 +87,7 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance,
},
context: {
tabId: remote ? undefined : activeTabId,
tabId: activeTabId,
items: runtime.items, context: runtime.context, usage: runtime.usage,
sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost,
sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens,
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
workspaceConflict: WorkspaceConflictView | null;
pendingClose: PendingClose;
clearContextPending: boolean;
remote?: boolean;
}): AppDecisionSurfaceKind | null {
// A remote session's prompts and forms are answered by RemoteSessionSurface,
// the one owner of the serve's exact prompt route; a footer copy would be a
// second card whose answer has no local session to reach.
if (!input.remote) {
const prompt = projectPromptSurface(input);
if (prompt) return prompt;
}
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
function projectPromptSurface(input: {
approval: State["approval"];
ask: State["ask"];
mcpInteraction: State["mcpInteraction"];
extensionForm: State["extensionForm"];
}): AppDecisionSurfaceKind | null {
if (input.approval) {
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
if (input.ask) return "ask";
if (input.mcpInteraction) return "mcp_interaction";
if (input.extensionForm) return "extension_form";
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
workspaceConflict, pendingClose, clearContextPending,
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
const visibleDecisionSurface = decisionSurface;
// Navigation used to hide the entire composer until the controller/runtime
// activation ticket and the transcript paint ticket both settled. That made
@@ -31,7 +31,7 @@ export function useRuntimeNotifications(activeTabId: string | undefined) {
pending.current = [];
};
}, [readPorts]);
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string; outcome?: string }) => {
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
});
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
</button>
</div>
<section className="sidebar__section sidebar__section--projects">
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
<ProjectTree {...props.projectTree} />
</Suspense>
</section>
<nav className="sidebar__nav sidebar__nav--footer">
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
@@ -1,6 +1,5 @@
import type { ReactNode } from "react";
import { PanelLeft } from "lucide-react";
import logoSymbol from "../assets/logo-symbol.svg";
import { Tooltip } from "../components/Tooltip";
import { WorktreeBadge } from "../components/WorktreeBadge";
@@ -36,7 +35,7 @@ export function TopicbarRegion({ view, commands, children }: {
const { sidebar, title, subtitle } = view;
return <header className="topicbar">
{view.brand && <div className="topicbar__brand">
<img src={logoSymbol} alt="Reasonix" className="topicbar__brand-logo" draggable={false} />
<span role="img" aria-label="Reasonix" className="topicbar__brand-logo" />
</div>}
{view.automationReturn && <button className="btn btn--small" type="button" onClick={event => {
event.currentTarget.focus({ preventScroll: true });
@@ -144,7 +144,7 @@ function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind:
// Empty delivery accounting is not a chat result. Keep meaningful records in details.
if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null;
if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}>
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} />
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} diagnostic={item.diagnostic} />
{summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>}
{item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>}
{item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>}
@@ -1,13 +1,14 @@
import { useId, useState } from "react";
import { useI18n } from "../lib/i18n";
import { presentError } from "../lib/errorPresentation";
import type { WireEvent } from "../lib/types";
/** Inline-safe error surface, including inside paragraphs and existing banners. */
export function ErrorMessage({ error, summary, onInspect }: { error: unknown; summary?: string; onInspect?: () => void }) {
export function ErrorMessage({ error, summary, diagnostic, onInspect }: { error: unknown; summary?: string; diagnostic?: WireEvent["diagnostic"]; onInspect?: () => void }) {
const { t, locale } = useI18n();
const id = useId();
const [expandedError, setExpandedError] = useState<{ error: unknown } | null>(null);
const presentation = presentError(error, t, locale);
const presentation = presentError(error, t, locale, diagnostic);
const expanded = expandedError !== null && expandedError.error === error;
const title = summary || presentation.summary;
const detail = error == null || error === "" ? "" : presentation.detail || (summary ? presentation.summary : "");
@@ -128,6 +128,7 @@ export function ProjectTree({
const compactTopics = variant === "workbench";
const creationTopics = variant === "creation";
const [tree, setTree] = useState<ProjectNode[]>([]);
const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
const treeRef = useRef<ProjectNode[]>([]);
const latestRevisionRef = useRef(0);
const shellRequestRef = useRef(0);
@@ -490,10 +491,11 @@ export function ProjectTree({
})));
await reloadRequestedProjects(projects);
} catch (err) {
// A shell snapshot is metadata-only. If it fails, the resident folder
// identity can still drive the requested canonical topic reload.
// A failed shell read can still reload topics from resident folders.
if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
if (throwOnSnapshotError) throw err;
} finally {
if (request === shellRequestRef.current) setShellStage("ready");
}
}, [applyRuntimeProjection, reloadProjectTopicLists]);
refreshRef.current = refresh;
@@ -561,6 +563,10 @@ export function ProjectTree({
return () => { shellRequestRef.current++; };
}, [refresh, refreshSignal]);
useEffect(() => {
if (shellStage !== "loading") return;
const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
}, [shellStage]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
@@ -2010,6 +2016,11 @@ export function ProjectTree({
);
const renderEmptyState = () => {
if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
{shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
<span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
</div>}
</div>;
if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
return (
<div className="project-tree__empty-state">
@@ -313,7 +313,10 @@ export function useProjectTreeOrganization({
const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
if (bindings.UpdateSessionOrganization && dragged) {
const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
} else if (bindings.UpdateSessionOrganization && dragged) {
void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
.then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
@@ -326,7 +329,7 @@ export function useProjectTreeOrganization({
};
props.onDragEnd = clearTopicDrag;
return { className, props };
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
const removeTopicFromGroups = useCallback((node: ProjectNode) => {
const key = projectTreeOrganizationKey(node);
@@ -45,7 +45,9 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
const visiblePrompt = approval || ask || mcpInteraction;
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
// runtimeEpoch stays optional: a serve fences on it only when it has one,
// and a CLI serve never binds desktop routing metadata.
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
));
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
@@ -6652,6 +6652,19 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
setPathMessage(t("settings.hooksJsonClipboardUnavailable"));
}
};
const approve = async () => {
setBusy(true);
setErr(null);
try {
await app.TrustProjectHooksForRoot(view?.projectRoot?.trim() ?? "");
await load(scope);
onChanged();
} catch (e) {
setErr(String((e as Error)?.message ?? e));
} finally {
setBusy(false);
}
};
const save = async () => {
setBusy(true);
setErr(null);
@@ -6694,6 +6707,12 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
title={t("settings.hooks")}
description={scope === "project" ? t("settings.hooksProjectHint") : t("settings.hooksGlobalHint")}
>
{view && scope === "project" && !view.trusted && view.hooks.length > 0 && (
<div className="banner banner--warning" role="status">
<span>{t("settings.hooksAwaitingApproval")}</span>
<button className="btn btn--small" disabled={busy} onClick={() => void approve()}>{t("settings.hooksApprove")}</button>
</div>
)}
{view && (
<div className="hooks-json-panel">
<div className="hooks-json-panel__head">
@@ -45032,6 +45032,13 @@
"type": {
"kind": "string"
}
},
{
"name": "transportCode",
"optional": true,
"type": {
"kind": "string"
}
}
]
},
@@ -3,7 +3,7 @@
export const DESKTOP_PROTOCOL_VERSION = 11;
export const DESKTOP_CONTRACT_DIGEST = "sha256:857039e0868d57e1e367b080f009abd2d21baa856ae5d57e55fc8c3b1cef5734";
export const DESKTOP_CONTRACT_DIGEST = "sha256:b4fd48e18f5b4e766fb0da34f9d382961a0f9e07ee8385ac7b387babf63fa6e0";
export const DESKTOP_COMMANDS = [
"AIRenameSession",
@@ -5202,6 +5202,7 @@ export interface FailureDiagnostic {
providerDisplayName?: string;
protocol?: string;
requestPath?: string;
transportCode?: string;
}
export interface MCPAppPresentation {
+2 -2
View File
@@ -4672,10 +4672,10 @@ function makeMockApp(): MockAppBindings {
hookSettings[key].hooks = JSON.parse(JSON.stringify(hooks)) as HookConfigView[];
},
async TrustProjectHooks() {
// Compatibility no-op: project hooks are enabled automatically.
hookSettings.project.trusted = true;
},
async TrustProjectHooksForRoot(_projectRoot: string) {
// Compatibility no-op: project hooks are enabled automatically.
hookSettings.project.trusted = true;
},
async SetDefaultModel(ref: string) {
settings.defaultModel = ref;
@@ -4,12 +4,12 @@ import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./
import type { Item } from "./useController";
import { readPauseItem } from "./readPause";
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, diagnostic?: HistoryMessage["diagnostic"]): { items: Item[]; seq: number } {
if (quietTranscriptNoticeKey(rawText, code)) return { items, seq };
const text = localizedNoticeText(rawText, code);
if (quietTranscriptNoticeKey(text, code)) return { items, seq };
const trimmedDetail = detail?.trim();
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
return { items: [...items, { kind: "notice", id, level, text, ...(diagnostic ? { diagnostic } : {}), ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
}
export function errorMessage(err: unknown): string {
@@ -190,5 +190,5 @@ export function historyNoticeItems(m: HistoryMessage, id: string): Item[] {
if (quietTranscriptNoticeKey(text, m.code)) return [];
const detail = m.detail?.trim();
return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text,
...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
...(m.diagnostic ? { diagnostic: m.diagnostic } : {}), ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
}
@@ -1,4 +1,5 @@
import { t, getLocale, type DictKey, type Translator, type Locale } from "./i18n";
import type { WireEvent } from "./types";
export interface ErrorPresentation { summary: string; detail: string }
@@ -24,6 +25,7 @@ const codeKeys: Record<string, DictKey> = {
invalid_api_key: "error.auth", insufficient_quota: "error.quota", rate_limit_exceeded: "error.rateLimit",
stream_interrupted: "error.interrupted", empty_response: "error.empty", stale_generation: "error.conflict",
provider_connection: "error.connection", cancelled: "error.cancelled",
transport_protocol: "error.transportProtocol",
};
// Narrow legacy matches bridge errors from older services and OS libraries.
@@ -61,8 +63,11 @@ function statusKey(status: number): DictKey | undefined {
if (status === 400 || status === 422) return "error.invalidInput";
}
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale()): ErrorPresentation {
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale(), diagnostic?: WireEvent["diagnostic"]): ErrorPresentation {
const detail = errorDetail(error).trim();
if (diagnostic?.kind === "transport_protocol") {
return { summary: translate("error.transportProtocol"), detail: [detail, diagnostic.transportCode ? `HTTP/2: ${diagnostic.transportCode}` : ""].filter(Boolean).join("\n") };
}
const structured = error && typeof error === "object" ? error as { code?: unknown; status?: unknown } : undefined;
const code = typeof structured?.code === "string" ? structured.code : detail;
const explicitStatus = typeof structured?.status === "number" ? structured.status : 0;
+1 -1
View File
@@ -102,7 +102,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
continue;
}
if (m.content.trim() !== "" || m.decisionReceipt) {
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt, m.diagnostic);
items = next.items;
seq = next.seq;
}
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
return "empty";
}
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
input.dispatch({ type: "history_older_error", error: "history identity changed" });
return "empty";
}
@@ -3,7 +3,11 @@ import { attentionChimeEventKey, clearAttentionChimeKeys, playAttentionChime, pl
import type { Translator } from "./i18n";
import type { ToastContextValue } from "./toast";
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string } } | { resetTabId?: string };
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string; outcome?: string } } | { resetTabId?: string };
// Outcomes where the turn stopped to wait on the user; the host records them as
// paused or awaiting_delivery (desktop/topic_status.go), never as a finish.
const PAUSED_TURN_OUTCOMES = new Set(["final_readiness", "recovery_paused", "incomplete_read", "completion_uncertain"]);
type NotificationPorts = { activeTabId: string | undefined; t: Translator; showToast: ToastContextValue["showToast"] };
/** One owner deduplicates view events and authoritative background snapshots. */
@@ -72,7 +76,9 @@ export function createRuntimeNotifications(readPorts: () => NotificationPorts |
clearAttentionChimeKeys(seen, operation.resetTabId);
clearAttentionChimeKeys(pendingSeen, operation.resetTabId);
} else if (operation.event.kind === "turn_done") {
if (readPorts() && !operation.event.err) playSuccessChime();
if (!readPorts()) return;
if (PAUSED_TURN_OUTCOMES.has(operation.event.outcome ?? "")) playAttentionChime();
else if (!operation.event.err) playSuccessChime();
} else handleAttention(operation.event);
},
start() { stop = runtimeStateStore.subscribe(handleSnapshot); handleSnapshot(); },
+2 -2
View File
@@ -449,7 +449,7 @@ export interface WireEvent extends RecoveryEventFields {
outcome?: "completed" | "partial" | "blocked" | "final_readiness" | "recovery_paused" | "completion_uncertain" | "incomplete_read";
readiness?: WireFinalReadiness;
protocolRecovery?: { id: string };
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string; transportCode?: string };
/** Optional: "headers" | "stream". Older clients ignore unknown fields. */
retryScope?: "headers" | "stream" | "protocol";
streamAttempt?: WireStreamAttempt;
@@ -856,7 +856,7 @@ export interface HistoryMessage extends TranscriptTurnMetadata {
decisionReceipt?: WireDecisionReceipt;
readiness?: WireFinalReadiness;
protocolRecovery?: { id: string };
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string; transportCode?: string };
serverSearch?: HistoryServerSearch[];
attachments?: Array<{ kind?: string; digest?: string; name?: string; mime?: string; width?: number; height?: number; bytes?: number }>;
}
+6 -6
View File
@@ -340,7 +340,7 @@ export type Item = { turnId?: string } & (
| { kind: "user"; id: string; messageId?: string; submissionId?: string; submissionState?: "sending" | "confirmed" | "failed" | "unknown"; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number; historyTurn?: number }
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; turnFinal?: boolean; samplingCount?: number; toolCount?: number; wasStreamed?: true; reasoningComplete?: boolean; reasoningDurationMs?: number; workDurationMs?: number; turnDurationMs?: number; turnUsage?: TurnUsage; tokensPerSecond?: number; createdAt?: number; memoryCitations?: MemoryCitation[]; searchSources?: SearchSource[] }
| { kind: "phase"; id: string; text: string }
| { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
| { kind: "notice"; id: string; local?: boolean; level: "info" | "warn"; text: string; detail?: string; diagnostic?: WireEvent["diagnostic"]; code?: string; title?: string; variant?: "delivery" | "completion"; action?: "continue_delivery" | "open_changes" | "recover_context"; recoveryId?: string; completionSummary?: WireCompletionSummary; decisionReceipt?: WireDecisionReceipt; missing?: string[]; inboxItemId?: string }
| {
kind: "compaction";
id: string;
@@ -1646,7 +1646,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
case "notice": {
const noticeId = e.code === "unapplied_steer" && e.messageId ? `he:m:${e.messageId}` : undefined;
if (noticeId && s.items.some(item => item.id === noticeId)) return s;
const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt, noticeId);
const next = appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt, noticeId, e.diagnostic);
return e.code?.startsWith("stream_interrupted_") ? { ...next, streamInterruptNoticeShown: true } : next;
}
case "context_maintenance": {
@@ -1815,11 +1815,11 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
});
}
if (e.err && e.diagnostic && e.diagnostic.kind !== "cancelled" && !s.streamInterruptNoticeShown) {
interruptItems.push({ kind: "notice", id: `e${s.seq + interruptItems.length}`, level: "warn", text: e.err, detail: e.detail });
interruptItems.push({ kind: "notice", id: `e${s.seq + interruptItems.length}`, level: "warn", text: e.err, detail: e.detail, ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}) });
}
items = [...finalized, ...interruptItems];
} else if (e.err && !s.streamInterruptNoticeShown) {
items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail }];
items = [...finalized, { kind: "notice", id: `e${s.seq}`, level: "warn", text: e.err, detail: e.detail, ...(e.diagnostic ? { diagnostic: e.diagnostic } : {}) }];
}
if (e.protocolRecovery?.id && e.status !== "interrupted" && !s.cancelRequested) {
items = items.map(item => item.kind==="notice" && item.action==="recover_context" ? {...item,action:undefined} : item);
@@ -2318,8 +2318,8 @@ function getOrCreateState(states: TabStates, tabId: string): State {
return states.get(tabId)!;
}
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, id?: string): State {
const next = appendNoticeItem(s.items, s.seq, id ?? `n${s.seq}`, level, text, detail, code, decisionReceipt);
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, id?: string, diagnostic?: WireEvent["diagnostic"]): State {
const next = appendNoticeItem(s.items, s.seq, id ?? `n${s.seq}`, level, text, detail, code, decisionReceipt, diagnostic);
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
}
+4
View File
@@ -6,6 +6,7 @@
export const en = {
"error.unknown": "An error occurred. Open details to view the reported cause.",
"error.connection": "Could not connect. Check the network, proxy and service address.",
"error.transportProtocol": "The HTTP/2 connection failed with a protocol error. Check the service or proxy compatibility; expand the details for error information.",
"error.dns": "The server address could not be resolved. Check the address and DNS settings.",
"error.interrupted": "The connection was interrupted. Check the network or service before trying again.",
"error.timeout": "The request timed out. Check the service and connection before trying again.",
@@ -1683,6 +1684,7 @@ export const en = {
"projectTree.colorPink": "Pink",
"projectTree.emptyNoMatch": "No matching projects or sessions",
"projectTree.emptyNoProjects": "No projects yet",
"projectTree.loadingProjects": "Reading projects…",
"projectTree.newTopicTooltip": "New session",
"projectTree.addProjectTooltip": "Add new project",
"projectTree.remoteOpenWindow": "Open in remote window",
@@ -3020,6 +3022,8 @@ export const en = {
"settings.hooksPathUnavailable": "No project settings file is available",
"settings.hooksGlobalHint": "Saved globally. Restart Reasonix to load new hooks.",
"settings.hooksProjectHint": "Saved in this workspace. Restart Reasonix to load new hooks.",
"settings.hooksAwaitingApproval": "These hooks came with this workspace and stay off until you approve them as they are now. Any later change needs approval again.",
"settings.hooksApprove": "Approve",
"settings.hooksJsonTitle": "JSON config",
"settings.hooksJsonHint": "Edit the {\"hooks\": ...} block from settings.json directly. Arrays with event fields are also accepted; saving validates and formats the JSON.",
"settings.hooksJsonCopy": "Copy JSON",
+4
View File
@@ -7,6 +7,7 @@ import type { DictKey } from "./en";
export const zhTW: Record<DictKey, string> = {
"error.unknown": "操作發生問題,請展開詳情查看原始錯誤。",
"error.connection": "無法連線至服務,請檢查網路、代理和服務位址。",
"error.transportProtocol": "HTTP/2 連線發生協定錯誤,請檢查服務端或代理的相容性,展開詳情可查看錯誤資訊。",
"error.dns": "無法解析伺服器位址,請檢查位址和 DNS 設定。",
"error.interrupted": "連線意外中斷,請檢查網路或服務狀態後再試。",
"error.timeout": "請求逾時,請檢查服務和網路連線後再試。",
@@ -1437,6 +1438,7 @@ export const zhTW: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
"projectTree.emptyNoProjects": "還沒有專案",
"projectTree.loadingProjects": "正在讀取專案…",
"projectTree.newTopicTooltip": "新建會話",
"projectTree.addProjectTooltip": "新增專案",
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
@@ -3507,6 +3509,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.hooksPathUnavailable": "沒有可用的專案設定檔案",
"settings.hooksGlobalHint": "儲存為全域性配置。重啟 Reasonix 後載入新的 hooks。",
"settings.hooksProjectHint": "儲存在當前工作區。重啟 Reasonix 後載入新的 hooks。",
"settings.hooksAwaitingApproval": "這些 hooks 隨工作區而來,在你核准目前內容之前不會執行;之後有任何變動都需要重新核准。",
"settings.hooksApprove": "核准",
"settings.hooksJsonTitle": "JSON 配置",
"settings.hooksJsonHint": "直接編輯 settings.json 中的 {\"hooks\": ...}。也支援貼上帶 event 欄位的 hook 陣列;儲存前會校驗並格式化。",
"settings.hooksJsonCopy": "複製 JSON",
+4
View File
@@ -7,6 +7,7 @@ import type { DictKey } from "./en";
export const zh: Record<DictKey, string> = {
"error.unknown": "操作出现问题,请展开详情查看原始报错。",
"error.connection": "无法连接服务,请检查网络、代理和服务地址。",
"error.transportProtocol": "HTTP/2 连接发生协议错误,请检查服务端或代理的兼容性,展开详情可查看错误信息。",
"error.dns": "无法解析服务器地址,请检查地址和 DNS 设置。",
"error.interrupted": "连接意外中断,请检查网络或服务状态后再试。",
"error.timeout": "请求超时,请检查服务和网络连接后再试。",
@@ -1684,6 +1685,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
"projectTree.emptyNoProjects": "还没有项目",
"projectTree.loadingProjects": "正在读取项目…",
"projectTree.newTopicTooltip": "新建会话",
"projectTree.addProjectTooltip": "添加新项目",
"projectTree.remoteOpenWindow": "在远程窗口打开",
@@ -3023,6 +3025,8 @@ export const zh: Record<DictKey, string> = {
"settings.hooksPathUnavailable": "没有可用的项目设置文件",
"settings.hooksGlobalHint": "保存为全局配置。重启 Reasonix 后加载新的 hooks。",
"settings.hooksProjectHint": "保存在当前工作区。重启 Reasonix 后加载新的 hooks。",
"settings.hooksAwaitingApproval": "这些 hooks 随工作区而来,在你批准当前内容之前不会运行;之后有任何改动都需要重新批准。",
"settings.hooksApprove": "批准",
"settings.hooksJsonTitle": "JSON 配置",
"settings.hooksJsonHint": "直接编辑 settings.json 中的 {\"hooks\": ...}。也支持粘贴带 event 字段的 hook 数组;保存前会校验并格式化。",
"settings.hooksJsonCopy": "复制 JSON",
+3 -1
View File
@@ -22277,7 +22277,9 @@ body > .mermaid-diagram--fullscreen {
display: block;
width: 22px;
height: 22px;
object-fit: contain;
background: var(--accent);
-webkit-mask: url("./assets/logo-symbol.svg") no-repeat center / contain;
mask: url("./assets/logo-symbol.svg") no-repeat center / contain;
}
.app--windows-frameless .sidebar__head,
.app--windows-frameless .sidebar__brand,
+5 -5
View File
@@ -35,7 +35,7 @@ require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.2 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -45,13 +45,13 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/larksuite/oapi-sdk-go/v3 v3.11.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pkg/sftp v1.13.11 // indirect
@@ -77,7 +77,7 @@ require (
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/sh/v3 v3.14.0 // indirect
mvdan.cc/sh/v3 v3.14.1 // indirect
)
replace reasonix => ../
+10 -10
View File
@@ -10,8 +10,8 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -56,8 +56,8 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -70,10 +70,10 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 h1:Sxti6bD15KmHiTqcJ54nt9G6885m8DVwAxZX6Iu4Kjc=
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
@@ -228,5 +228,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
+102 -2
View File
@@ -5,11 +5,14 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/agent"
"reasonix/internal/session"
"reasonix/internal/store"
)
// Explicit source archives stage content as archived from the beginning. They
@@ -38,11 +41,33 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
slog.Warn("desktop: historical archive failed", "source_key", id, "operation", operationID, "err", err)
return SessionMutationResult{}, sessionOperationErrorForTarget(err, id, operationID)
}
archivedPaths := []string{source.path}
siblings := legacyHeadVersions(source)
for _, path := range recoveredLegacySiblings(source) {
siblings = append(siblings, SessionSourceRef{HostID: localDesktopHostID, Path: path})
}
for _, sibling := range siblings {
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &sibling})
if err != nil {
slog.Warn("desktop: sibling archive skipped", "source_key", id, "err", err)
continue
}
siblingResult, err := a.archiveHistoricalSourceWithOperation(ctx, siblingID, siblingSource,
"archive-source-"+strings.TrimPrefix(newTabID(), "tab_"))
if err != nil {
slog.Warn("desktop: sibling archive failed", "source_key", siblingID, "err", err)
continue
}
result.IdentityAliases = append(result.IdentityAliases, siblingResult.IdentityAliases...)
archivedPaths = append(archivedPaths, siblingSource.path)
}
c := &a.historicalImports
c.mu.Lock()
for i := range c.catalog {
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, source.path) {
c.catalog[i].sourceChanged = false
for _, path := range archivedPaths {
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, path) {
c.catalog[i].sourceChanged = false
}
}
}
c.mu.Unlock()
@@ -50,6 +75,81 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
return result, nil
}
// recoveredLegacySiblings lists the other recovery copies of the conversation a
// recovered legacy source was copied from: same directory, recovered, and the
// same parent in the metadata their writer recorded. The sidebar lists each
// copy as its own row of the same conversation, so archiving one row alone
// only brings the next copy up in its place.
func recoveredLegacySiblings(source historicalSource) []string {
if source.format != "legacy" {
return nil
}
meta, ok, err := agent.LoadBranchMeta(source.path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.ParentID) == "" {
return nil
}
dir := filepath.Dir(source.path)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var siblings []string
for _, entry := range entries {
if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
continue
}
path := filepath.Join(dir, entry.Name())
if sameDesktopPath(path, source.path) {
continue
}
other, ok, err := agent.LoadBranchMeta(path)
if err == nil && ok && other.Recovered && other.ParentID == meta.ParentID {
siblings = append(siblings, path)
}
}
return siblings
}
// legacyHeadVersions lists the other live heads of a legacy transcript that are
// versions of the selected head's conversation: each rewind keeps the head it
// left as a version, and the sidebar lists every version as its own row with
// the same title. Named forks start a conversation of their own and stay.
func legacyHeadVersions(source historicalSource) []SessionSourceRef {
if source.format != "legacy" || source.head == "" {
return nil
}
heads, err := agent.ListSessionHeads(source.path)
if err != nil {
return nil
}
byID := make(map[string]agent.SessionHead, len(heads))
for _, head := range heads {
byID[head.ID] = head
}
origin := func(id string) string {
for range heads {
head, ok := byID[id]
if !ok || head.Kind != agent.HeadKindRewind || head.ParentHead == "" {
break
}
id = head.ParentHead
}
return id
}
if _, ok := byID[source.head]; !ok {
return nil
}
want := origin(source.head)
var versions []SessionSourceRef
for _, head := range heads {
if head.ID == source.head || head.Retired || head.Kind == agent.HeadKindConcurrent || origin(head.ID) != want {
continue
}
versions = append(versions, SessionSourceRef{HostID: localDesktopHostID, Path: source.path, HeadID: head.ID})
}
return versions
}
func (a *App) archiveHistoricalSourceWithOperation(ctx context.Context, id string, source historicalSource, operationID string) (SessionMutationResult, error) {
release, err := acquireHistoricalSource(ctx, id, source)
if err != nil {
@@ -0,0 +1,98 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
// A write conflict leaves one recovery copy per writer, each listed as its own
// row of the same conversation, so archiving any of them archives them all.
func TestArchivingRecoveredLegacyRowArchivesEveryCopy(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
parent := filepath.Join(dir, "20260826-101500.000000000-fake-model.jsonl")
p := agent.NewSession("system")
p.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
if err := p.Save(parent); err != nil {
t.Fatal(err)
}
meta := agent.BranchMeta{Scope: "global", TopicID: "topic_jd", TopicTitle: "shops from my search"}
for _, answer := range []string{"first writer", "second writer", "third writer"} {
s := agent.NewSession("system")
s.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
s.Add(provider.Message{ID: "a-" + answer, Role: provider.RoleAssistant, Content: answer})
if _, err := s.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parent, Reason: "conflict", BranchMeta: meta}); err != nil {
t.Fatal(err)
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), "20260826-101500.000000000-fake-model.") {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
// An unrelated recovered conversation must survive.
other := filepath.Join(dir, "20260826-120000.000000000-fake-model.jsonl")
o := agent.NewSession("system")
o.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "another conversation"})
if err := o.Save(other); err != nil {
t.Fatal(err)
}
if _, err := o.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: other, Reason: "conflict",
BranchMeta: agent.BranchMeta{Scope: "global", TopicID: "topic_other", TopicTitle: "another conversation"}}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
if err != nil {
t.Fatal(err)
}
app.sessionCatalog.Store(catalog)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
recoveredRows := func(topic string) []ProjectNode {
t.Helper()
if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
app.ReleaseReadSnapshot(page.SnapshotID)
var rows []ProjectNode
for _, n := range page.Items {
if n.Recovered && n.TopicID == topic {
rows = append(rows, n)
}
}
return rows
}
rows := recoveredRows("topic_jd")
if len(rows) != 3 {
t.Fatalf("recovered rows before archive = %d, want the 3 copies", len(rows))
}
target := rows[0]
if _, err := app.ArchiveSessionTarget(SessionSelector{Ref: target.Session, Source: target.Source, SessionPath: target.SessionPath}); err != nil {
t.Fatal(err)
}
if left := recoveredRows("topic_jd"); len(left) != 0 {
t.Fatalf("archiving one recovered row left %d copies of the same conversation listed", len(left))
}
if kept := recoveredRows("topic_other"); len(kept) != 1 {
t.Fatalf("an unrelated recovered conversation was archived too: %d rows left, want 1", len(kept))
}
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/agent"
"reasonix/internal/provider"
)
// Every rewind keeps the head it left as a version, and the sidebar lists each
// version of a legacy transcript as its own row with the same title. Archiving
// one of them archives the conversation, once, however often it is repeated.
func TestArchivingLegacyVersionRowArchivesEveryVersionOnce(t *testing.T) {
isolateDesktopUserDirs(t)
path, legacy, mainHead := migrationSingleDAGFixture(t)
legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
versions := []string{mainHead}
for _, retry := range []string{"second answer", "third answer", "fourth answer"} {
head, err := legacy.ForkHead(path, "question", agent.HeadKindRewind, "")
if err != nil {
t.Fatal(err)
}
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: retry})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
versions = append(versions, head)
}
fork, err := legacy.ForkHead(path, "question", agent.HeadKindFork, "separate idea")
if err != nil {
t.Fatal(err)
}
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "forked answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
before, err := desktopSourceFingerprint(path)
if err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
root := pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
installSessionCatalogForTest(t, app, filepath.Dir(path), "global", "")
rowHeads := func() map[string]bool {
t.Helper()
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
heads := map[string]bool{}
for _, row := range page.Items {
if row.Source == nil {
t.Fatalf("unexpected materialized row: %+v", row)
}
heads[row.Source.HeadID] = true
}
return heads
}
if got := rowHeads(); len(got) != len(versions)+1 || !got[fork] {
t.Fatalf("fixture rows = %v; want %d versions and the fork", got, len(versions))
}
selector := SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: versions[2]}}
var dirs int
for attempt := range 3 {
result, err := app.ArchiveSessionTarget(selector)
if err != nil || !result.Committed {
t.Fatalf("archive %d = %+v, %v", attempt, result, err)
}
if got := rowHeads(); len(got) != 1 || !got[fork] {
t.Fatalf("archive %d left rows %v; want only the fork", attempt, got)
}
state, err := app.workspaceRegistry().Load(t.Context())
if err != nil {
t.Fatal(err)
}
if len(state.SourceMappings) != len(versions) || len(state.SessionStates) != len(versions) {
t.Fatalf("archive %d: %d mappings, %d sessions; want one per version (%d)",
attempt, len(state.SourceMappings), len(state.SessionStates), len(versions))
}
mapped := map[string]string{}
for _, mapping := range state.SourceMappings {
if !sameDesktopPath(mapping.Path, path) {
t.Fatalf("mapping for another source: %+v", mapping)
}
if previous, dup := mapped[mapping.HeadID]; dup {
t.Fatalf("head %s materialized twice: %s and %s", mapping.HeadID, previous, mapping.SessionID)
}
mapped[mapping.HeadID] = mapping.SessionID
if lifecycle := state.SessionStates[mapping.SessionID].Lifecycle; lifecycle != workspacestate.Archived {
t.Fatalf("version %s is %v, not archived", mapping.HeadID, lifecycle)
}
}
for _, head := range versions {
if _, ok := mapped[head]; !ok {
t.Fatalf("version %s was not archived: %v", head, mapped)
}
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
if attempt == 0 {
dirs = len(entries)
} else if len(entries) != dirs {
t.Fatalf("archive %d wrote new session storage: %d entries, was %d", attempt, len(entries), dirs)
}
}
if after, err := desktopSourceFingerprint(path); err != nil || after != before {
t.Fatalf("archive changed the legacy source: %v", err)
}
}
+54 -11
View File
@@ -2,12 +2,15 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"reasonix/internal/config"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/hook"
@@ -37,10 +40,15 @@ func (a *App) HooksSettings(scope string) HooksSettingsView {
Scope: s,
Path: path,
ProjectRoot: root,
// Retained for older Wails clients. Both scopes are enabled by default.
Trusted: true,
Hooks: []HookConfigView{},
Events: hookEventNames(),
Hooks: []HookConfigView{},
Events: hookEventNames(),
}
// Project hooks run only once approved as they stand now; what this view
// showed is what the Approve button may approve.
program, pending := hook.PendingProjectHooks(hook.LoadOptions{ProjectRoot: root})
view.Trusted = s != string(hook.ScopeProject) || !pending
if !view.Trusted {
shownProjectHooks.Store(root, program.Digest)
}
settings, err := readHooksSettingsFile(path)
if err != nil || settings.Hooks == nil {
@@ -85,21 +93,56 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
if s == string(hook.ScopeProject) && strings.TrimSpace(path) == "" {
return fmt.Errorf("no active project workspace")
}
return writeHooksSettingsFile(path, settings)
if err := writeHooksSettingsFile(path, settings); err != nil {
return err
}
// Saving from the editor is the person choosing these hooks as written.
if s == string(hook.ScopeProject) {
return hook.ApproveProjectHooksAs(hook.LoadOptions{ProjectRoot: projectRoot}, settings)
}
return nil
}
func (a *App) TrustProjectHooks() error {
// Retained for older generated Wails clients. Project hooks are enabled by
// default, so there is no trust state to mutate.
return nil
return a.TrustProjectHooksForRoot(a.activeHookProjectRoot())
}
// errHooksNotShown refuses approving hooks that carry fields this editor
// cannot display: a person may only approve what they were shown.
var errHooksNotShown = errors.New("these project hooks set env or contextFile, which this editor does not show; review and approve them with `reasonix trust`")
// TrustProjectHooksForRoot approves root's project hooks as they stand now.
func (a *App) TrustProjectHooksForRoot(root string) error {
// Retained for older generated Wails clients. Project hooks are enabled by
// default, so there is no trust state to mutate.
return nil
if strings.TrimSpace(root) == "" {
return fmt.Errorf("no active project workspace")
}
settings, err := readHooksSettingsFile(hook.ProjectSettingsPath(root))
if err != nil {
return err
}
for _, list := range settings.Hooks {
for _, cfg := range list {
if len(cfg.Env) > 0 || strings.TrimSpace(cfg.ContextFile) != "" {
return errHooksNotShown
}
}
}
program, ok := hook.ProjectHooksProgram(root)
if !ok {
return nil
}
if shown, _ := shownProjectHooks.Load(root); shown != program.Digest {
return errHooksChangedSinceShown
}
return config.NewProjectProgramStore(config.ReasonixHomeDir()).Approve(root, program)
}
// shownProjectHooks is the digest of the project hooks each root's settings
// view last showed as waiting, so approving never covers a later rewrite.
var shownProjectHooks sync.Map
var errHooksChangedSinceShown = errors.New("these project hooks changed after they were shown; reload them and review again")
func (a *App) activeHookProjectRoot() string {
a.mu.RLock()
defer a.mu.RUnlock()
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/hook"
)
// Hooks a checkout ships are shown as waiting, and load once approved.
func TestTrustProjectHooksApprovesTheCheckoutsHooks(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.MkdirAll(filepath.Join(project, ".reasonix"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(project, ".reasonix", "settings.json"), []byte(`{"hooks":{"Stop":[{"command":"echo done"}]}}`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: project, Ready: true},
}
app.activeTabID = "project"
if view := app.HooksSettings("project"); view.Trusted {
t.Fatalf("unapproved checkout hooks read as trusted: %+v", view)
}
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 0 {
t.Fatalf("unapproved checkout hooks loaded: %+v", loaded)
}
if err := app.TrustProjectHooks(); err != nil {
t.Fatalf("TrustProjectHooks: %v", err)
}
if view := app.HooksSettings("project"); !view.Trusted {
t.Fatalf("approved hooks still read as waiting: %+v", view)
}
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 1 {
t.Fatalf("approved hooks = %+v, want one", loaded)
}
if err := app.TrustProjectHooksForRoot(""); err == nil {
t.Fatal("approving with no workspace succeeded")
}
}
// Approving from the editor covers only what the editor shows.
func TestTrustProjectHooksRefusesFieldsTheEditorCannotShow(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.MkdirAll(filepath.Join(project, ".reasonix"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(project, ".reasonix", "settings.json"), []byte(`{"hooks":{"Stop":[{"command":"echo done","env":{"NODE_OPTIONS":"--require ./x.js"}}]}}`), 0o644); err != nil {
t.Fatal(err)
}
if err := NewApp().TrustProjectHooksForRoot(project); !errors.Is(err, errHooksNotShown) {
t.Fatalf("TrustProjectHooksForRoot = %v, want errHooksNotShown", err)
}
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 0 {
t.Fatalf("hooks loaded after a refused approval: %+v", loaded)
}
}
// The Approve button covers what the view showed, not a later rewrite.
func TestTrustProjectHooksRefusesHooksChangedSinceShown(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
settings := filepath.Join(project, ".reasonix", "settings.json")
if err := os.MkdirAll(filepath.Dir(settings), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settings, []byte(`{"hooks":{"Stop":[{"command":"echo shown"}]}}`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
if err := app.TrustProjectHooksForRoot(project); !errors.Is(err, errHooksChangedSinceShown) {
t.Fatalf("approving hooks never shown = %v, want errHooksChangedSinceShown", err)
}
app.tabs = map[string]*WorkspaceTab{"p": {ID: "p", Scope: "project", WorkspaceRoot: project, Ready: true}}
app.activeTabID = "p"
_ = app.HooksSettings("project")
if err := os.WriteFile(settings, []byte(`{"hooks":{"Stop":[{"command":"echo swapped"}]}}`), 0o644); err != nil {
t.Fatal(err)
}
if err := app.TrustProjectHooksForRoot(project); !errors.Is(err, errHooksChangedSinceShown) {
t.Fatalf("approving a rewrite = %v, want errHooksChangedSinceShown", err)
}
if loaded := hook.Load(hook.LoadOptions{ProjectRoot: project}); len(loaded) != 0 {
t.Fatalf("rewritten hooks loaded: %+v", loaded)
}
}
+12 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
@@ -26,7 +27,7 @@ func TestRemoteInboxQueueCapabilitiesAndIdentity(t *testing.T) {
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
defer server.Close()
a, tab := remoteRuntimeTestApp(server.Client())
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
@@ -105,3 +106,13 @@ func TestTargetGuidanceQueuesEndedTurnAndRejectsReplacementSession(t *testing.T)
t.Fatal("legacy request bypassed the session fence")
}
}
// operatorServeHandler stands in for the remote client, which holds the
// launch token a serve requires for mutations.
func operatorServeHandler(s *serve.Server) http.Handler {
h := s.Handler()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Authorization", "Bearer "+s.AuthToken())
h.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -73,7 +73,7 @@ func TestRemoteInboxTargetLostReceiptOnlyQueriesOriginalRequest(t *testing.T) {
if err := ctrl.SetInboxPaused(true); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
defer server.Close()
posts, lookups := 0, 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+4 -1
View File
@@ -131,7 +131,10 @@ func (a *App) ClearMCPServerAuthentication(name string) error {
return err
}
}
if _, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name); err != nil {
if err := config.KeepMCPDecisionAcross(root, name, func() (config.PluginEntry, error) {
updated, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name)
return updated, err
}); err != nil {
return err
}
disconnectMCPServerControllers(name, ctrl, controllers)
+1 -1
View File
@@ -477,7 +477,7 @@ func setConnectionsCredentialConfig(c *config.Config, names []string, key string
}
entries = append(entries, *p)
}
env, err := c.StageModelCredentialLocked(key)
env, err := c.RotateModelCredentialLocked(names, key)
if err != nil {
return err
}
+4 -3
View File
@@ -10,7 +10,8 @@ import (
"reasonix/internal/netclient"
)
func TestNetworkProxySpecForRootMatchesEffectiveProjectConfig(t *testing.T) {
// The proxy is the user's: a checkout's [network] is ignored.
func TestNetworkProxySpecForRootKeepsTheUsersProxy(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o700); err != nil {
t.Fatal(err)
@@ -27,8 +28,8 @@ func TestNetworkProxySpecForRootMatchesEffectiveProjectConfig(t *testing.T) {
}
spec := NewApp().networkProxySpecForRoot(root)
if spec.Mode != netclient.ModeCustom || spec.URL != "http://127.0.0.1:9876" {
t.Fatalf("model probe proxy = %+v, want effective project proxy", spec)
if spec.Mode == netclient.ModeCustom || spec.URL != "" {
t.Fatalf("model probe proxy = %+v, want the user's proxy_mode off", spec)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/permission"
"reasonix/internal/plugin"
"reasonix/internal/tool"
)
func validateSavedPermissionRule(list, raw string, tools []tool.ContractEntry, servers []string) error {
rule, ok := permission.ParseRule(raw)
if !ok {
return nil // AddPermissionRule reports malformed rules.
}
for _, candidate := range tools {
if permission.RuleMatchesString(rule.Tool, candidate.Name, "") {
return nil
}
}
for _, server := range servers {
prefix := plugin.ToolPrefix(server)
suffix := strings.TrimPrefix(rule.Tool, prefix)
if (suffix != rule.Tool && validConfiguredMCPToolSuffix(suffix)) || rule.Tool == plugin.MCPConnectPermissionName(server) {
return nil
}
}
if strings.HasPrefix(rule.Tool, "mcp__") && strings.ContainsAny(rule.Tool, "*?") {
return fmt.Errorf("permission rule %q uses an MCP tool-name wildcard; MCP permission rules require an exact tool name", raw)
}
if rule.Subject == "" && !strings.ContainsAny(rule.Tool, "()") {
// Allow uses an exact reusable command; ask/deny use a command prefix
// so arguments to the same command remain covered.
if strings.EqualFold(strings.TrimSpace(list), "allow") {
return fmt.Errorf("permission rule %q names no registered tool; if this is a shell command, use Bash(%s)", raw, rule.Tool)
}
return fmt.Errorf("permission rule %q names no registered tool; if this is a shell command, use Bash(%s:*)", raw, rule.Tool)
}
return fmt.Errorf("permission rule %q names no registered tool", raw)
}
func validConfiguredMCPToolSuffix(suffix string) bool {
if suffix == "" {
return false
}
for _, c := range suffix {
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '_' || c == '-') {
return false
}
}
return true
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/tool"
)
func TestAddPermissionRuleRejectsBareShellCommandBeforeSaving(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
if err := (&App{}).AddPermissionRule("deny", "rm"); err == nil || !strings.Contains(err.Error(), "Bash(rm:*)") {
t.Fatalf("AddPermissionRule(deny, rm) = %v, want a Bash rule suggestion", err)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("rejected rule wrote config: stat error = %v", err)
}
cfg := config.Default()
cfg.Permissions.Deny = []string{"rm"} // Existing entries must not be rewritten.
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save existing config: %v", err)
}
before, err := os.ReadFile(config.UserConfigPath())
if err != nil {
t.Fatal(err)
}
if err := (&App{}).AddPermissionRule("deny", "git reset"); err == nil {
t.Fatal("bare command was accepted into existing config")
}
after, err := os.ReadFile(config.UserConfigPath())
if err != nil || !bytes.Equal(before, after) {
t.Fatalf("rejected rule changed existing config: read error = %v", err)
}
}
func TestValidateSavedPermissionRuleUsesRegisteredTools(t *testing.T) {
registered := []tool.ContractEntry{{Name: "bash"}, {Name: "write_file"}, {Name: "plugin_custom"}}
for _, tc := range []struct {
list, rule, suggestion string
}{
{"deny", "rm", "Bash(rm:*)"},
{"ask", "git reset", "Bash(git reset:*)"},
{"allow", "git branch", "Bash(git branch)"},
} {
err := validateSavedPermissionRule(tc.list, tc.rule, registered, nil)
if err == nil || !strings.Contains(err.Error(), tc.suggestion) {
t.Errorf("%s %q: got %v, want %q suggestion", tc.list, tc.rule, err, tc.suggestion)
}
}
for _, rule := range []string{"Bash(rm:*)", "Edit(src/**)", "plugin_custom"} {
if err := validateSavedPermissionRule("deny", rule, registered, nil); err != nil {
t.Errorf("registered rule %q: %v", rule, err)
}
}
if err := validateSavedPermissionRule("deny", "Bash(rm:*)", tool.BuiltinContractEntries(), nil); err != nil {
t.Errorf("desktop built-in Bash rule: %v", err)
}
}
func TestAddPermissionRuleAcceptsConfiguredDisconnectedMCPServer(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
cfg := config.Default()
cfg.Plugins = []config.PluginEntry{{Name: "github", Type: "http", URL: "https://example.invalid/mcp"}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
app := &App{} // No active session or connected MCP registry.
for _, rule := range []string{"mcp__github__create_issue", "mcp_connect__github"} {
if err := app.AddPermissionRule("deny", rule); err != nil {
t.Fatalf("configured disconnected MCP rule %q: %v", rule, err)
}
}
if err := app.AddPermissionRule("deny", "mcp__other__create_issue"); err == nil {
t.Fatal("accepted an unconfigured MCP server")
}
if err := app.AddPermissionRule("deny", "mcp__github__*"); err == nil || !strings.Contains(err.Error(), "exact tool name") {
t.Fatalf("MCP tool-name glob = %v, want exact-name guidance", err)
}
got, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
if err != nil {
t.Fatal(err)
}
if len(got.Permissions.Deny) != 2 || got.Permissions.Deny[0] != "mcp__github__create_issue" || got.Permissions.Deny[1] != "mcp_connect__github" {
t.Fatalf("saved MCP deny rules = %q", got.Permissions.Deny)
}
}
+61 -61
View File
@@ -68,8 +68,8 @@ importers:
specifier: 1.7.5
version: 1.7.5(@modelcontextprotocol/[email protected]([email protected]))([email protected]([email protected]))([email protected])([email protected])
'@tanstack/react-virtual':
specifier: ^3.14.10
version: 3.14.11([email protected]([email protected]))([email protected])
specifier: ^3.14.13
version: 3.14.13([email protected]([email protected]))([email protected])
'@xterm/addon-fit':
specifier: 0.11.0
version: 0.11.0
@@ -89,8 +89,8 @@ importers:
specifier: 3.0.1
version: 3.0.1
katex:
specifier: ^0.18.6
version: 0.18.7
specifier: ^0.18.9
version: 0.18.9
lucide-react:
specifier: ^1.41.0
version: 1.43.0([email protected])
@@ -156,11 +156,11 @@ importers:
specifier: ^19.2.7
version: 19.2.7(@types/[email protected])
'@typescript-eslint/parser':
specifier: ^8.69.0
version: 8.70.0([email protected])([email protected])
specifier: ^8.70.1
version: 8.70.1([email protected])([email protected])
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected])([email protected]3))
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected])([email protected]5))
decode-named-character-reference:
specifier: ^1.3.0
version: 1.3.0
@@ -186,14 +186,14 @@ importers:
specifier: ^5.51.2
version: 5.51.2
tsx:
specifier: ^4.23.13
version: 4.23.13
specifier: ^4.23.15
version: 4.23.15
typescript:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: ^8.2.2
version: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]3)
version: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]5)
packages:
@@ -776,14 +776,14 @@ packages:
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tanstack/[email protected]1':
resolution: {integrity: sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g==}
'@tanstack/[email protected]3':
resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/[email protected].9':
resolution: {integrity: sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg==}
'@tanstack/[email protected].11':
resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==}
'@types/[email protected]':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -940,41 +940,41 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/[email protected]':
@@ -1823,8 +1823,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected].7:
resolution: {integrity: sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==}
[email protected].9:
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
hasBin: true
[email protected]:
@@ -2507,8 +2507,8 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]3:
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
[email protected]5:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -3255,13 +3255,13 @@ snapshots:
'@standard-schema/[email protected]': {}
'@tanstack/[email protected]1([email protected]([email protected]))([email protected])':
'@tanstack/[email protected]3([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/virtual-core': 3.17.9
'@tanstack/virtual-core': 3.17.11
react: 19.2.8
react-dom: 19.2.8([email protected])
'@tanstack/[email protected].9': {}
'@tanstack/[email protected].11': {}
'@types/[email protected]': {}
@@ -3444,44 +3444,44 @@ snapshots:
'@types/[email protected]': {}
'@typescript-eslint/[email protected].0([email protected])([email protected])':
'@typescript-eslint/[email protected].1([email protected])([email protected])':
dependencies:
'@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.70.0([email protected])
'@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/typescript-estree': 8.70.1([email protected])
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3
eslint: 10.10.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0([email protected])':
'@typescript-eslint/[email protected].1([email protected])':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.70.0([email protected])
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/tsconfig-utils': 8.70.1([email protected])
'@typescript-eslint/types': 8.70.1
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0':
'@typescript-eslint/[email protected].1':
dependencies:
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/visitor-keys': 8.70.1
'@typescript-eslint/[email protected].0([email protected])':
'@typescript-eslint/[email protected].1([email protected])':
dependencies:
typescript: 6.0.3
'@typescript-eslint/[email protected].0': {}
'@typescript-eslint/[email protected].1': {}
'@typescript-eslint/[email protected].0([email protected])':
'@typescript-eslint/[email protected].1([email protected])':
dependencies:
'@typescript-eslint/project-service': 8.70.0([email protected])
'@typescript-eslint/tsconfig-utils': 8.70.0([email protected])
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/project-service': 8.70.1([email protected])
'@typescript-eslint/tsconfig-utils': 8.70.1([email protected])
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3
minimatch: 10.2.6
semver: 7.8.5
@@ -3491,9 +3491,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0':
'@typescript-eslint/[email protected].1':
dependencies:
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/types': 8.70.1
eslint-visitor-keys: 5.0.1
'@ungap/[email protected]': {}
@@ -3503,10 +3503,10 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1([email protected])
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]3))':
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]5))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]3)
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]5)
'@xmldom/[email protected]': {}
@@ -4425,7 +4425,7 @@ snapshots:
dependencies:
commander: 8.3.0
[email protected].7:
[email protected].9:
dependencies:
commander: 15.0.0
@@ -5374,7 +5374,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
[email protected]3:
[email protected]5:
dependencies:
esbuild: 0.28.1
optionalDependencies:
@@ -5476,7 +5476,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected](@types/[email protected])([email protected])([email protected])([email protected]3):
[email protected](@types/[email protected])([email protected])([email protected])([email protected]5):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -5488,7 +5488,7 @@ snapshots:
esbuild: 0.28.1
fsevents: 2.3.3
terser: 5.51.2
tsx: 4.23.13
tsx: 4.23.15
[email protected]:
dependencies:
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/plugin"
)
// enableProjectMCPForTest records the user's enable decision for each server
// root's project files declare with auto_start on.
func enableProjectMCPForTest(t testing.TB, root string) {
t.Helper()
enableProjectMCPForWorkspace(t, root, root)
}
// enableProjectMCPForWorkspace records the decision under workspace, which the
// test app's global tab leaves empty while config still loads from root.
func enableProjectMCPForWorkspace(t testing.TB, root, workspace string) {
t.Helper()
cfg, err := config.LoadForRootReadOnly(root)
if err != nil {
t.Fatal(err)
}
for _, p := range cfg.Plugins {
if p.Source.ProjectScoped() && p.ShouldAutoStart() {
if err := config.DefaultMCPActivationStore().SetServerEnabled(p, workspace, true); err != nil {
t.Fatal(err)
}
}
}
}
// Project configuration cannot choose MCP servers the desktop starts: without
// the user's decision the shared host never connects to one.
func TestDesktopSharedHostProjectMCPWaitsForTheUser(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
srv := desktopMCPHTTPServer(t)
defer srv.Close()
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "h"
type = "http"
url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sharedHost := plugin.NewHost()
defer sharedHost.Close()
ctrl, err := boot.Build(ctx, boot.Options{
WorkspaceRoot: dir,
SessionDir: filepath.Join(dir, "sessions"),
SharedHost: sharedHost,
Stderr: io.Discard,
})
if err != nil {
t.Fatalf("boot.Build: %v", err)
}
defer ctrl.Close()
time.Sleep(time.Second)
if sharedHost.HasClient("h") {
t.Fatal("project MCP connected with no user decision")
}
}
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
}
return
}
}
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
+21 -4
View File
@@ -1,19 +1,36 @@
package main
import "errors"
// registerProjectRoot indexes workspaceRoot, realigns open tabs to its
// canonical spelling, and discovers existing sessions once per process.
func (a *App) registerProjectRoot(workspaceRoot string) {
_ = addProject(workspaceRoot, "")
func (a *App) registerProjectRoot(workspaceRoot string) error {
if err := addProject(workspaceRoot, ""); errors.Is(err, errProjectStateCollisionAssignment) {
return err
}
a.syncTabWorkspaceRootSpellings()
root := normalizeProjectRoot(workspaceRoot)
if root == "" {
return
return nil
}
registrationKey := projectRootKey(root)
if _, loaded := a.catalogRegisteredProjectRoots.LoadOrStore(registrationKey, struct{}{}); loaded {
return
return nil
}
if !a.requestSessionCatalogReconcile(desktopSessionDir(root)) {
a.catalogRegisteredProjectRoots.Delete(registrationKey)
}
return nil
}
func (a *App) beginRegisteredProjectRuntimeAdmission(tab *WorkspaceTab, scope, workspaceRoot string) (func(), error) {
release, err := a.beginChangedProjectRuntimeAdmission(tab, scope, workspaceRoot)
if err != nil {
return nil, err
}
if err := a.registerProjectRoot(workspaceRoot); err != nil {
release()
return nil, err
}
return release, nil
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"errors"
"reasonix/internal/config"
)
var errProjectStateCollisionAssignment = errors.New("project state collision assignment")
// assignAddedProjectStateCollisions assigns a new root only when a recorded
// project still uses the same legacy directory. Recorded roots and existing
// assignments keep their current directory.
func assignAddedProjectStateCollisions(previous []string, projects []desktopProject) error {
known := append([]string(nil), previous...)
userDir := config.MemoryUserDir()
for _, project := range projects {
root := normalizeProjectRoot(project.Root)
if root == "" {
continue
}
alreadyKnown := false
collision := false
for _, prior := range known {
if sameProjectRoot(prior, root) {
alreadyKnown = true
break
}
if config.WorkspaceSlug(prior) == config.WorkspaceSlug(root) &&
config.ProjectStateDir(userDir, prior) == config.ProjectStateDir(userDir, root) {
collision = true
}
}
if alreadyKnown {
continue
}
if collision {
if err := config.AssignProjectStateCollision(userDir, root); err != nil {
return err
}
}
known = append(known, root)
}
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
"reasonix/internal/memory"
"reasonix/internal/sessioncatalog"
)
func TestNewCollidingProjectGetsOwnStateWithoutMovingExistingProject(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
first := filepath.Join(root, "front-end", "app")
second := filepath.Join(root, "front", "end-app")
plain := filepath.Join(root, "backend")
for _, path := range []string{first, second, plain} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
if err := addProject(first, ""); err != nil {
t.Fatal(err)
}
firstDir := config.ProjectSessionDir(first)
if err := os.MkdirAll(firstDir, 0o700); err != nil {
t.Fatal(err)
}
oldSession := filepath.Join(firstDir, "existing.jsonl")
if err := os.WriteFile(oldSession, []byte("existing session\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := addProject(second, ""); err != nil {
t.Fatal(err)
}
secondDir := config.ProjectSessionDir(second)
if secondDir == firstDir {
t.Fatalf("new project still shares %q", firstDir)
}
if got := config.ProjectSessionDir(first); got != firstDir {
t.Fatalf("existing project moved to %q", got)
}
if got, err := os.ReadFile(oldSession); err != nil || string(got) != "existing session\n" {
t.Fatalf("existing session changed: %q, %v", got, err)
}
if err := removeProject(first); err != nil {
t.Fatal(err)
}
if err := addProject(first, ""); err != nil {
t.Fatal(err)
}
if got := config.ProjectSessionDir(first); got != firstDir {
t.Fatalf("re-added original project moved to %q, want %q", got, firstDir)
}
if _, err := os.Stat(filepath.Join(secondDir, "existing.jsonl")); !os.IsNotExist(err) {
t.Fatalf("new project sees existing session: %v", err)
}
if err := addProject(plain, ""); err != nil {
t.Fatal(err)
}
wantPlain := filepath.Join(config.MemoryUserDir(), "projects", config.WorkspaceSlug(plain), "sessions")
if got := config.ProjectSessionDir(plain); got != wantPlain {
t.Fatalf("non-colliding project moved to %q, want %q", got, wantPlain)
}
if err := removeProject(second); err != nil {
t.Fatal(err)
}
if err := addProject(second, ""); err != nil {
t.Fatal(err)
}
if got := config.ProjectSessionDir(second); got != secondDir {
t.Fatalf("re-added project moved to %q, want %q", got, secondDir)
}
if got := config.ProjectSessionStoreDir(first); got == config.ProjectSessionStoreDir(second) {
t.Fatalf("canonical session stores overlap: %q", got)
}
if got := config.DesktopTopicStatePath(first); got == config.DesktopTopicStatePath(second) {
t.Fatalf("topic stores overlap: %q", got)
}
if got := memory.StoreFor(config.MemoryUserDir(), first).Dir; got == memory.StoreFor(config.MemoryUserDir(), second).Dir {
t.Fatalf("project memory stores overlap: %q", got)
}
if err := os.MkdirAll(secondDir, 0o700); err != nil {
t.Fatal(err)
}
firstSession := writeTopicSession(t, firstDir, "first.jsonl", "first-topic", "First", first)
secondSession := writeTopicSession(t, secondDir, "second.jsonl", "second-topic", "Second", second)
app := NewApp()
installSessionCatalogForTest(t, app, firstDir, "project", first)
reconcileSessionCatalogForTest(t, app, secondDir, "project", second)
for _, tc := range []struct{ root, dir, want, other string }{
{first, firstDir, firstSession, secondSession},
{second, secondDir, secondSession, firstSession},
} {
page, err := app.sessionCatalog.Load().ListSessions(context.Background(), sessioncatalog.SessionPageRequest{
Scope: "project", WorkspaceRoot: tc.root, Directory: tc.dir, Limit: 20,
})
if err != nil {
t.Fatal(err)
}
found := false
for _, item := range page.Items {
if item.Path == tc.other {
t.Fatalf("project %q lists another project's session %q", tc.root, tc.other)
}
found = found || item.Path == tc.want
}
if !found {
t.Fatalf("project %q did not list its session %q: %+v", tc.root, tc.want, page.Items)
}
}
}
func TestLegacyProjectImportsKeepExistingCollisionState(t *testing.T) {
for _, source := range []string{"workspace-list", "sidebar-recovery"} {
t.Run(source, func(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
first := filepath.Join(root, "front-end", "app")
second := filepath.Join(root, "front", "end-app")
for _, path := range []string{first, second} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
if err := addProject(first, ""); err != nil {
t.Fatal(err)
}
legacyDir := config.ProjectSessionDir(second)
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
t.Fatal(err)
}
oldSession := filepath.Join(legacyDir, "legacy.jsonl")
if err := os.WriteFile(oldSession, []byte("legacy session\n"), 0o600); err != nil {
t.Fatal(err)
}
switch source {
case "workspace-list":
rememberWorkspace(second)
migrateLegacyWorkspacesIntoProjects()
case "sidebar-recovery":
if _, err := recoverLegacyProjectSidebarRoots(desktopTabsFile{Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: second}}}); err != nil {
t.Fatal(err)
}
}
if got := config.ProjectSessionDir(second); got != legacyDir {
t.Fatalf("imported project moved to %q, want %q", got, legacyDir)
}
if got, err := os.ReadFile(oldSession); err != nil || string(got) != "legacy session\n" {
t.Fatalf("imported session changed: %q, %v", got, err)
}
})
}
}
func TestProjectRegistrationKeepsOpeningOnProjectListLockFailure(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
lockPath := filepath.Join(desktopConfigDir(), desktopProjectsFile+".lock")
if err := os.MkdirAll(lockPath, 0o700); err != nil {
t.Fatal(err)
}
app := NewApp()
if err := app.registerProjectRoot(root); err != nil {
t.Fatalf("project list lock failure blocked opening: %v", err)
}
}
func TestCollidingProjectRegistrationFailsBeforeUsingUnownedState(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
first := filepath.Join(root, "front-end", "app")
second := filepath.Join(root, "front", "end-app")
if err := addProject(first, ""); err != nil {
t.Fatal(err)
}
if err := config.AssignProjectStateCollision(config.MemoryUserDir(), second); err != nil {
t.Fatal(err)
}
assigned := config.ProjectStateDir(config.MemoryUserDir(), second)
if err := os.Remove(filepath.Join(assigned, ".workspace-root")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assigned, "orphan"), []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
if err := addProject(second, ""); err == nil {
t.Fatal("registration accepted an unowned, nonempty assigned directory")
}
if len(loadProjectsFile().Projects) != 1 {
t.Fatal("failed registration changed the saved project list")
}
}
func TestPreviouslyRecordedCollisionKeepsBothLegacyPaths(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
root := t.TempDir()
first := filepath.Join(root, "front-end", "app")
second := filepath.Join(root, "front", "end-app")
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: first}, {Root: second}}}); err != nil {
t.Fatal(err)
}
legacy := config.ProjectSessionDir(first)
for _, path := range []string{first, second} {
if err := addProject(path, ""); err != nil {
t.Fatal(err)
}
if got := config.ProjectSessionDir(path); got != legacy {
t.Fatalf("previously recorded project %q moved to %q", path, got)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import "encoding/json"
// remoteContextSnapshot reads the selected remote session, then rejects a
// response that raced with a reconnect or a switch to another session.
func (a *App) remoteContextSnapshot(tabID string) (int, int, bool) {
a.remoteTabMu.Lock()
tab := a.remoteTabs[tabID]
if tab == nil || tab.client == nil || tab.state != "ready" || tab.routing.rehydratingPath != "" {
a.remoteTabMu.Unlock()
return 0, 0, false
}
client, base := tab.client, tab.base
path, generation, revision := tab.routing.currentPath, tab.gen, tab.routing.pathRevision
a.remoteTabMu.Unlock()
ctx, cancel := commandContext(a)
defer cancel()
data, err := serveGet(ctx, client, serveURL(base, "/context"), path)
if err != nil {
return 0, 0, false
}
var value struct {
Used int `json:"used"`
Window int `json:"window"`
}
if json.Unmarshal(data, &value) != nil {
return 0, 0, false
}
a.remoteTabMu.Lock()
current := a.remoteTabs[tabID]
valid := current == tab && current.client == client && current.gen == generation &&
current.routing.pathRevision == revision && current.routing.currentPath == path &&
current.routing.rehydratingPath == "" && current.state == "ready"
a.remoteTabMu.Unlock()
return value.Used, value.Window, valid
}
+64
View File
@@ -0,0 +1,64 @@
package main
import "testing"
func TestRemoteContextPanelReadsServeContext(t *testing.T) {
fs := newFakeServe(t, "s3cret", nil)
fs.newSessionPath = "/sessions/remote.jsonl"
kernel := &fakeRemoteKernel{
statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
ensureToken: "s3cret",
}
seedBridgeTestHost(t, "box")
a := &App{remoteRuntime: kernel}
cleanupRemoteTabPumps(t, a)
meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
if err != nil {
t.Fatal(err)
}
waitForTabState(t, a, meta.ID, "ready")
if got := a.ContextPanel(meta.ID); got.UsedTokens != 10 || got.WindowTokens != 128 {
t.Fatalf("remote panel context = %d/%d, want Serve /context value 10/128", got.UsedTokens, got.WindowTokens)
}
if got := a.ContextUsageForTab(meta.ID); got.Used != 10 || got.Window != 128 {
t.Fatalf("remote usage context = %d/%d, want 10/128", got.Used, got.Window)
}
fs.mu.Lock()
expected := fs.contextExpected
fs.mu.Unlock()
a.remoteTabMu.Lock()
selected := a.remoteTabs[meta.ID].routing.currentPath
a.remoteTabMu.Unlock()
if expected != selected {
t.Fatalf("remote context request targeted %q, want selected %q", expected, selected)
}
a.remoteTabMu.Lock()
a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "canonical-a"
a.remoteTabs[meta.ID].routing.pathRevision++
a.remoteTabMu.Unlock()
if got := a.ContextPanel(meta.ID); got.UsedTokens != 10 {
t.Fatalf("canonical remote panel used tokens = %d, want 10", got.UsedTokens)
}
fs.mu.Lock()
expected = fs.contextExpected
fs.mu.Unlock()
if expected != remoteSessionIDRoutePrefix+"canonical-a" {
t.Fatalf("canonical context header = %q", expected)
}
started, release := make(chan struct{}, 1), make(chan struct{})
fs.mu.Lock()
fs.contextStarted, fs.contextRelease = started, release
fs.mu.Unlock()
done := make(chan ContextPanelInfo, 1)
go func() { done <- a.ContextPanel(meta.ID) }()
<-started
a.remoteTabMu.Lock()
a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "canonical-b"
a.remoteTabs[meta.ID].routing.pathRevision++
a.remoteTabMu.Unlock()
close(release)
if got := <-done; got.UsedTokens != 0 || got.WindowTokens != 0 {
t.Fatalf("stale remote context reached the switched tab: %+v", got)
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ func TestRemoteRuntimeInboxReceiptSnapshotAndConsumptionOverHTTP(t *testing.T) {
}
}
})
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
defer server.Close()
a, tab := remoteRuntimeTestApp(server.Client())
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
+1
View File
@@ -110,6 +110,7 @@ func TestRemoteModelSourceRefreshesAutonomousHTTPRunAndRetiresOldRoute(t *testin
request := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(`{"input":"autonomous next run"}`)).WithContext(ctx)
request.Host = "127.0.0.1"
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+srv.AuthToken())
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != http.StatusAccepted {
+22 -1
View File
@@ -53,6 +53,8 @@ type fakeServe struct {
eventsCloseEarly bool // return immediately after the initial 200 frames
statusPayload string
statusAfterCancel string
contextExpected string
contextStarted, contextRelease chan struct{}
}
func (fs *fakeServe) eventsCount() int { fs.mu.Lock(); defer fs.mu.Unlock(); return fs.eventsConns }
@@ -305,6 +307,25 @@ func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fak
mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) {
fs.record(r.Method, path, "")
responsePayload := payload
if path == "/context" {
fs.mu.Lock()
fs.contextExpected = r.Header.Get(expectedSessionPathHeader)
if id := r.Header.Get(expectedSessionIDHeader); id != "" {
fs.contextExpected = remoteSessionIDRoutePrefix + id
}
started, release := fs.contextStarted, fs.contextRelease
fs.mu.Unlock()
if started != nil {
started <- struct{}{}
}
if release != nil {
select {
case <-release:
case <-r.Context().Done():
return
}
}
}
if path == "/history" {
fs.mu.Lock()
fail := fs.failHistory
@@ -333,7 +354,7 @@ func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fak
})
}
snapshot("/history", `[{"role":"user","content":"hi"}]`)
snapshot("/context", `{"used":10}`)
snapshot("/context", `{"used":10,"window":128}`)
snapshot("/todos", `[]`)
snapshot("/checkpoints", `[{"turn":1}]`)
snapshot("/models", `{"current":"remote/chat","label":"chat","models":[{"ref":"remote/chat","provider":"remote","model":"chat","active":true}]}`)
+8 -1
View File
@@ -442,11 +442,18 @@ func (a *App) clearRemotePendingEvent(tabID, kind, callID string) {
// serveGet fetches a JSON member of the tab snapshot, returning the raw
// payload for verbatim passthrough.
func serveGet(ctx context.Context, client *http.Client, url string) (json.RawMessage, error) {
func serveGet(ctx context.Context, client *http.Client, url string, expectedPath ...string) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if len(expectedPath) != 0 && strings.TrimSpace(expectedPath[0]) != "" {
if sessionID, ok := strings.CutPrefix(expectedPath[0], remoteSessionIDRoutePrefix); ok {
req.Header.Set(expectedSessionIDHeader, sessionID)
} else {
req.Header.Set(expectedSessionPathHeader, expectedPath[0])
}
}
resp, err := client.Do(req)
if err != nil {
return nil, err
@@ -0,0 +1,69 @@
package main
import (
"context"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/serve"
"reasonix/internal/transcript"
)
// A Serve whose foreground is a path-backed session (the controller POST
// /resume installs for a historical .jsonl) still serves Follow v2 through the
// legacy projection, so the handshake a desktop attaches with must say so.
func TestRemoteFollowAttachesToServeWithPathBackedForeground(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
bc := serve.NewBroadcaster()
ctrl := control.New(control.Options{Executor: agent.New(nil, nil, sess, agent.Options{}, bc), SessionDir: dir, SessionPath: path, Sink: bc})
defer ctrl.Close()
if ctrl.UsesExclusiveSession() {
t.Fatal("fixture must put a path-backed session in the foreground")
}
server := httptest.NewServer(serve.New(ctrl, bc, config.ServeConfig{AuthMode: "token", Token: "fixture-token"}).Handler())
defer server.Close()
client, err := newServeHTTPClient(server.URL)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
capabilities, err := serveHandshakeCapabilities(ctx, client, server.URL, "fixture-token")
if err != nil {
t.Fatal(err)
}
tab := &remoteTab{id: "remote", state: "ready", client: client, base: server.URL, gen: 1,
routing: remoteTabSessionRouting{currentPath: path}, capabilities: map[string]bool{}}
for _, capability := range capabilities {
tab.capabilities[capability] = true
}
app := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
followed, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{})
if err != nil {
t.Fatalf("follow against a path-backed foreground (capabilities %v): %v", capabilities, err)
}
if followed.ProtocolVersion != transcript.FollowProtocolVersion || followed.Snapshot == nil || followed.Subscription == "" || followed.StorageBackend != "legacy" {
t.Fatalf("follow = %+v", followed)
}
if len(followed.Snapshot.Records) != 2 {
t.Fatalf("follow snapshot records = %d, want 2", len(followed.Snapshot.Records))
}
if _, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{Subscription: followed.Subscription, Close: true}); err != nil {
t.Fatalf("close follow: %v", err)
}
}
@@ -0,0 +1,98 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
// A save event indexes a transcript under the global fallback scope when its
// sidecar names none, while archive assigns the owner from the project store
// the file lives in. The row must leave the sidebar it was listed in anyway.
func TestArchivedSourceOwnedByAnotherWorkspaceLeavesSidebar(t *testing.T) {
for _, heads := range []int{1, 2} {
name := "single-head"
if heads == 2 {
name = "branched"
}
t.Run(name, func(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := filepath.Join(t.TempDir(), "project")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
t.Fatal(err)
}
dir := desktopSessionDir(workspace)
path := filepath.Join(dir, "20260922-122527.100224400-deepseek-chat.jsonl")
legacy := agent.NewSession("system")
legacy.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "question"})
legacy.Add(provider.Message{ID: "a0", Role: provider.RoleAssistant, Content: "answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
if heads == 2 {
if _, err := legacy.ForkHead(path, legacy.Snapshot()[1].ID, agent.HeadKindFork, "sibling"); err != nil {
t.Fatal(err)
}
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "sibling answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{TopicID: "topic_unscoped"}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
if err != nil {
t.Fatal(err)
}
app.sessionCatalog.Store(catalog)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
globalRows := func() []ProjectNode {
t.Helper()
// The desktop's persist observer indexes each save under this fallback.
if err := catalog.IndexSessionPath(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, path); err != nil {
t.Fatal(err)
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
app.ReleaseReadSnapshot(page.SnapshotID)
return page.Items
}
rows := globalRows()
if len(rows) != heads {
t.Fatalf("global rows before archive = %+v, want %d", rows, heads)
}
for _, row := range rows {
result, err := app.ArchiveSessionTarget(SessionSelector{Ref: row.Session, Source: row.Source, SessionPath: row.SessionPath})
if err != nil || !result.Committed {
t.Fatalf("archive %+v = %+v, %v", row.Source, result, err)
}
}
if left := globalRows(); len(left) != 0 {
var operationErr *SessionOperationError
err := app.SetSessionPinned(SessionSelector{Source: left[0].Source, SessionPath: left[0].SessionPath}, true)
if errors.As(err, &operationErr) {
t.Fatalf("archived rows stay listed (%d) and refuse every action with %q", len(left), operationErr.Code)
}
t.Fatalf("archived rows stay listed: %+v", left)
}
})
}
}
+4
View File
@@ -130,9 +130,11 @@ default = "model-a"
if err := os.WriteFile(filepath.Join(rootA, "reasonix.toml"), []byte(`default_model = "local/model-a"`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, rootA)
if err := os.WriteFile(filepath.Join(rootB, "reasonix.toml"), []byte(`default_model = "local/model-b"`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, rootB)
draftA, err := a.seedPreviousDraftForTarget("project", rootA)
if err != nil {
t.Fatal(err)
@@ -405,6 +407,8 @@ args = ["serve"]
`), 0o644); err != nil {
t.Fatal(err)
}
approveWorkspace(t, root)
enableProjectMCPForTest(t, root)
draft, err := a.seedPreviousDraftForTarget("project", root)
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -59,7 +59,7 @@ func (a *App) listSessionsFromDir(dir, active string) []SessionMeta {
Preview: node.Preview, Turns: node.Turns, TurnsState: node.TurnsState,
CreatedAt: node.CreatedAt, LastActivityAt: node.LastActivityAt, ModTime: node.LastActivityAt})
}
adopted := adoptedSourceRows(state, "")
adopted := adoptedSourceRows(state)
catalog := a.sessionCatalog.Load()
if catalog == nil {
return v3
+3 -1
View File
@@ -64,7 +64,9 @@ func (a *App) surfaceForCanonicalSession(ref session.SessionRef, workspace works
defer releaseAdmission()
if scope == "project" {
saveWorkspace(workspaceRoot)
a.registerProjectRoot(workspaceRoot)
if err := a.registerProjectRoot(workspaceRoot); err != nil {
return nil, nil, false, err
}
}
model, toolApprovalMode := desktopNewSessionDefaults(scope, actualRoot)
tab := &WorkspaceTab{
+2 -4
View File
@@ -18,12 +18,10 @@ var sourceHeadRows sync.Map
// A mapped DAG head must not return as an unidentified path placeholder while
// its index is unavailable. This is a display filter, not an ownership alias:
// independently identified siblings remain eligible for their own rows.
func adoptedSourceRows(state workspacestate.State, workspaceID string) map[string]bool {
// Adoption belongs to the source, not to the workspace a sidecar lists it in.
func adoptedSourceRows(state workspacestate.State) map[string]bool {
adopted := map[string]bool{}
for _, mapping := range state.SourceMappings {
if workspaceID != "" && mapping.WorkspaceID != workspaceID {
continue
}
for _, key := range state.SourceKeys(mapping.SourceKey) {
adopted["source\x00local\x00"+key] = true
}
+4 -3
View File
@@ -142,17 +142,18 @@ func (a *App) lazyProjectTopicSnapshot(req ProjectTopicPageRequest, reader works
func lazyTopicSourceExclusions(state workspacestate.State, workspaceID string) ([]string, bool) {
excluded := []string{}
for _, mapping := range state.SourceMappings {
if mapping.WorkspaceID != workspaceID {
continue
}
// Canonical directories have one source identity even if an older
// migration receipt retained their originating head ID.
if mapping.Format != "canonical" && !sourceMappingHasPathAlias(mapping) {
if mapping.WorkspaceID != workspaceID {
continue
}
// The catalog may not yet know about a new sibling, or the head
// index may be unavailable. Only the head-aware adapter can filter
// adopted identities without inventing a new path-only source.
return nil, false
}
// A sidecar can list a file outside the workspace that adopted it.
excluded = append(excluded, mapping.Path)
}
return excluded, true
+1 -1
View File
@@ -177,7 +177,7 @@ func (a *App) materializeProjectTopics(req ProjectTopicPageRequest, reader works
}
workspace.SessionIDs = admittedWorkspaceTopicMembers(req, state, workspace)
infos, _ := listWorkspaceSessionInfo(a.bootContext(), reader, workspace.SessionIDs)
adopted := adoptedSourceRows(state, workspaceID)
adopted := adoptedSourceRows(state)
adoptedTopics := state.AdoptedTopicIDs(workspaceID)
all := req
all.Cursor = ""
+27 -123
View File
@@ -30,6 +30,7 @@ import (
"reasonix/internal/netclient"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/tool"
)
// settings_app.go is the desktop Settings panel's command surface: it reads the
@@ -1502,45 +1503,23 @@ func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
return a.loadDesktopUserConfigForEditForRoot(a.activeWorkspaceRoot())
}
func (a *App) loadDesktopUserConfigForEditForRoot(root string) (*config.Config, string, error) {
// loadDesktopUserConfigForEditForRoot reads the user config alone. A
// workspace's reasonix.toml is never adopted into it: that file arrives with a
// checkout, and copying it would turn its sandbox, permission and [bot] values
// into the user's own.
func (a *App) loadDesktopUserConfigForEditForRoot(_ string) (*config.Config, string, error) {
userPath := config.UserConfigPath()
if userPath == "" {
return nil, "", fmt.Errorf("cannot resolve user config directory")
}
if _, err := os.Stat(userPath); err == nil {
cfg, err := config.LoadForEditReadOnlyStrict(userPath)
if err != nil {
return nil, "", err
}
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
return nil, "", err
}
if err := a.migrateLegacyBotConfigToUserForRoot(root, cfg, userPath); err != nil {
return nil, "", err
}
return cfg, userPath, nil
}
cfg, err := config.LoadForEditReadOnlyStrict(userPath)
if err != nil {
return nil, "", err
}
legacyPath := config.SourcePathForRoot(root)
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
return nil, "", err
}
return cfg, userPath, nil
}
legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
if err != nil {
if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
return nil, "", err
}
normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
legacyCfg.ConfigVersion = config.Default().ConfigVersion
if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil {
return nil, "", err
}
return legacyCfg, userPath, nil
return cfg, userPath, nil
}
// loadDesktopUserConfigForView loads the user config for read-only callers.
@@ -1574,94 +1553,18 @@ func (a *App) loadDesktopUserConfigForViewWithCredentialsForRoot(root string) (*
}
// loadDesktopUserConfigReadOnlyForRoot is the shared pure-read loader behind
// the View variants: same shape as loadDesktopUserConfigForEdit, but every
// legacy migration stays in memory (zero SaveTo) and resolves from root.
func (a *App) loadDesktopUserConfigReadOnlyForRoot(root string, load func(string) (*config.Config, error)) (*config.Config, string, error) {
// the View variants: the user config alone, never written to.
func (a *App) loadDesktopUserConfigReadOnlyForRoot(_ string, load func(string) (*config.Config, error)) (*config.Config, string, error) {
userPath := config.UserConfigPath()
if userPath == "" {
return nil, "", fmt.Errorf("cannot resolve user config directory")
}
if _, err := os.Stat(userPath); err == nil {
cfg, err := load(userPath)
if err != nil {
return nil, "", err
}
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
legacyPath := config.SourcePathForRoot(root)
if legacyPath != "" && !sameConfigPath(legacyPath, userPath) {
legacyCfg, err := load(legacyPath)
if err != nil {
return nil, "", err
}
mergeLegacyBotConfigInMemory(cfg, legacyCfg)
}
return cfg, userPath, nil
}
cfg, err := load(userPath)
if err != nil {
return nil, "", err
}
legacyPath := config.SourcePathForRoot(root)
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
return cfg, userPath, nil
}
// The user config does not exist yet: serve the legacy config as the view.
// It already carries any legacy bot config, so no merge is needed; the
// write path creates the migrated user file later.
legacyCfg, err := load(legacyPath)
if err != nil {
return nil, "", err
}
normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
legacyCfg.ConfigVersion = config.Default().ConfigVersion
return legacyCfg, userPath, nil
}
// migrateLegacyBotConfigToUserForRoot is the write-path legacy bot-config
// migration against an explicit workspace's legacy config file. Callers must
// hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit).
func (a *App) migrateLegacyBotConfigToUserForRoot(root string, userCfg *config.Config, userPath string) error {
if userCfg == nil {
return nil
}
legacyPath := config.SourcePathForRoot(root)
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
return nil
}
legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
if err != nil {
return err
}
return migrateLegacyBotConfigToUser(userCfg, legacyCfg, userPath)
}
// migrateLegacyBotConfigToUser is the write-path variant: it merges the legacy
// bot config in memory and persists the result to userPath. Callers must hold
// config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read paths
// use mergeLegacyBotConfigInMemory instead.
func migrateLegacyBotConfigToUser(userCfg, legacyCfg *config.Config, userPath string) error {
if !mergeLegacyBotConfigInMemory(userCfg, legacyCfg) {
return nil
}
if err := userCfg.SaveTo(userPath); err != nil {
return fmt.Errorf("migrate legacy bot config: %w", err)
}
return nil
}
// mergeLegacyBotConfigInMemory copies the legacy bot config onto userCfg when
// the user config has none of its own. It never touches disk; it reports
// whether userCfg changed (i.e. whether a write path should persist it).
func mergeLegacyBotConfigInMemory(userCfg, legacyCfg *config.Config) bool {
if userCfg == nil || legacyCfg == nil || desktopBotConfigConfigured(userCfg.Bot) {
return false
}
if !desktopBotConfigConfigured(legacyCfg.Bot) {
return false
}
userCfg.Bot = legacyCfg.Bot
return true
normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
return cfg, userPath, nil
}
func desktopBotConfigConfigured(bot config.BotConfig) bool {
@@ -1796,20 +1699,6 @@ func providerCredentialSourceNotice(apiKeyEnv, value string) string {
return ""
}
func sameConfigPath(a, b string) bool {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
aAbs, aErr := filepath.Abs(a)
bAbs, bErr := filepath.Abs(b)
if aErr == nil && bErr == nil {
return filepath.Clean(aAbs) == filepath.Clean(bAbs)
}
return filepath.Clean(a) == filepath.Clean(b)
}
// rebuild builds a replacement controller from the (just-changed) config and
// swaps it in only after the target session lease is available. The old
// controller stays usable if the rebuild fails.
@@ -2946,6 +2835,21 @@ func (a *App) SetPermissionMode(mode string) error {
// AddPermissionRule appends a rule to the allow/ask/deny list.
func (a *App) AddPermissionRule(list, rule string) error {
tools := tool.BuiltinContractEntries()
if ctrl, ok := a.activeCtrl().(interface{ AllToolContractEntries() []tool.ContractEntry }); ok {
tools = append(tools, ctrl.AllToolContractEntries()...)
}
cfg, err := config.LoadForRootWithoutCredentialsReadOnly(a.activeWorkspaceRoot())
if err != nil {
return err
}
servers := make([]string, 0, len(cfg.Plugins))
for _, entry := range cfg.Plugins {
servers = append(servers, entry.Name)
}
if err := validateSavedPermissionRule(list, rule, tools, servers); err != nil {
return err
}
return a.applyConfigChange(func(c *config.Config) error { return c.AddPermissionRule(list, rule) })
}
Loaded 100 of 675 files, more files were not shown because too many files have changed in this diff. Show more