Compare commits

...
51 Commits
Author SHA1 Message Date
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 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
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
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
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
460 changed files with 11228 additions and 37424 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:
+3 -3
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.
+8 -10
View File
@@ -7449,11 +7449,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 +8513,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 +8672,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 +8709,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 +9873,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()
+9 -33
View File
@@ -6909,39 +6909,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 +6973,7 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -8434,6 +8402,7 @@ command = "reasonix-missing-mcp-binary"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8961,6 +8930,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9022,6 +8992,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
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 +9027,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
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"))
@@ -9230,6 +9202,7 @@ tier = "background"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9280,6 +9253,7 @@ name = "codegraph"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
reg := tool.NewRegistry()
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
@@ -9338,6 +9312,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9431,6 +9406,7 @@ tier = "eager"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+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)
}
+3 -2
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)
+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
+1 -1
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",
@@ -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");
@@ -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",
);
@@ -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();
@@ -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();
@@ -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
@@ -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")}>
@@ -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">
@@ -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);
@@ -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";
}
+1
View File
@@ -1683,6 +1683,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",
+1
View File
@@ -1437,6 +1437,7 @@ export const zhTW: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
"projectTree.emptyNoProjects": "還沒有專案",
"projectTree.loadingProjects": "正在讀取專案…",
"projectTree.newTopicTooltip": "新建會話",
"projectTree.addProjectTooltip": "新增專案",
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
+1
View File
@@ -1684,6 +1684,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
"projectTree.emptyNoProjects": "还没有项目",
"projectTree.loadingProjects": "正在读取项目…",
"projectTree.newTopicTooltip": "新建会话",
"projectTree.addProjectTooltip": "添加新项目",
"projectTree.remoteOpenWindow": "在远程窗口打开",
+58 -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,29 @@ 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}
for _, sibling := range recoveredLegacySiblings(source) {
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: sibling}})
if err != nil {
slog.Warn("desktop: recovered 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: recovered 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 +71,41 @@ 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
}
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))
}
}
+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)
+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)
}
+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 {
@@ -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)
}
})
}
}
+1
View File
@@ -405,6 +405,7 @@ args = ["serve"]
`), 0o644); err != nil {
t.Fatal(err)
}
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
+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 = ""
+3
View File
@@ -2,6 +2,7 @@ package main
import (
"reasonix/internal/control"
"reasonix/internal/gitcmd"
goaldomain "reasonix/internal/goal"
"reasonix/internal/session"
)
@@ -75,6 +76,8 @@ type TabMeta struct {
ModelSettingsPending bool `json:"modelSettingsPending,omitempty"`
Active bool `json:"active"`
Cwd string `json:"cwd"`
// repo is the tab session's git identity for WorkspaceRoot; never serialized.
repo gitcmd.Repo
}
// setAuthenticationMeta only exposes pending settings belonging to this runtime.
+5 -2
View File
@@ -2138,7 +2138,7 @@ type wireEventTab struct {
func enrichTabMeta(meta TabMeta) TabMeta {
if meta.Active {
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot)
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot, meta.repo)
}
return meta
}
@@ -2146,7 +2146,7 @@ func enrichTabMeta(meta TabMeta) TabMeta {
func enrichTabMetas(metas []TabMeta) []TabMeta {
for i := range metas {
if metas[i].Active {
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot)
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot, metas[i].repo)
}
}
return metas
@@ -2192,6 +2192,9 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
Cwd: tab.WorkspaceRoot,
IsolatedWorktree: floor.isolated,
}
if repo, ok := sessionWorkspaceRepo(tab.Ctrl, tab.WorkspaceRoot); ok {
m.repo = repo
}
if strings.TrimSpace(tab.SessionID) != "" {
m.Session = &session.SessionRef{HostID: "local", SessionID: strings.TrimSpace(tab.SessionID)}
}
+3 -1
View File
@@ -480,7 +480,9 @@ func (a *App) refreshTabMetaExtras(tab *WorkspaceTab) {
gitBranch := ""
if root != "" {
gitBranch = workspaceGitBranch(root)
if repo, err := workspaceRepo(root, ctrl); err == nil {
gitBranch = workspaceGitBranch(repo)
}
}
imageInputEnabled := false
visionFallbackEnabled := false
@@ -0,0 +1,255 @@
package main
import (
"cmp"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/transcript"
)
// rendererFollowCursor mirrors TranscriptFollowClient.validate in
// desktop/frontend/src/lib/transcriptFollowClient.ts (lines 174-220): any
// error it returns is one that makes the renderer drop the subscription.
type rendererFollowCursor struct {
revision, coverage uint64
indexes map[string]uint64
attempts map[string]string
results map[string]uint64
resultOrder []string
deltas map[string]string
}
func newRendererFollowCursor(snapshot *transcript.Snapshot) *rendererFollowCursor {
c := &rendererFollowCursor{revision: snapshot.ProjectionRevision, coverage: snapshot.CoveredThroughSeq,
indexes: map[string]uint64{}, attempts: map[string]string{}, results: map[string]uint64{}, deltas: map[string]string{}}
for _, attempt := range snapshot.ActiveAttempts {
c.indexes[attempt.ID], c.attempts[attempt.ID] = attempt.NextIndex, attempt.MessageID
}
return c
}
func (c *rendererFollowCursor) noteResult(messageID string, sequence uint64) {
if _, ok := c.results[messageID]; !ok {
c.resultOrder = append(c.resultOrder, messageID)
}
c.results[messageID] = sequence
for len(c.resultOrder) > 192 {
delete(c.results, c.resultOrder[0])
c.resultOrder = c.resultOrder[1:]
}
}
func (c *rendererFollowCursor) accept(changes []transcript.Change) error {
ordered := slices.Clone(changes)
slices.SortFunc(ordered, func(a, b transcript.Change) int { return cmp.Compare(a.Revision, b.Revision) })
for _, change := range ordered {
if change.Revision <= c.revision {
continue
}
if change.ResetRequired || change.Revision != c.revision+1 {
return fmt.Errorf("revision_gap at revision %d", change.Revision)
}
if change.FirstSeq != 0 {
if change.FirstSeq != c.coverage+1 || change.CommitSeq < change.FirstSeq {
return fmt.Errorf("business_gap at revision %d: first=%d coverage=%d", change.Revision, change.FirstSeq, c.coverage)
}
c.coverage = change.CommitSeq
} else if change.CommitSeq != c.coverage {
return fmt.Errorf("frame_cut_mismatch at revision %d: commit=%d coverage=%d", change.Revision, change.CommitSeq, c.coverage)
}
e := change.Event
for _, record := range change.Records {
if record.MessageID != "" {
c.noteResult(record.MessageID, change.CommitSeq)
}
}
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action == "begin" {
if e.MessageID == "" {
return fmt.Errorf("sampling_identity_missing at revision %d", change.Revision)
}
c.indexes[e.StreamAttempt.ID], c.attempts[e.StreamAttempt.ID] = 0, e.MessageID
}
if change.AttemptID != "" && change.ResultSeq == 0 && (e == nil || e.Kind != "stream_attempt") {
if c.indexes[change.AttemptID] != change.Index {
return fmt.Errorf("sampling_gap at revision %d", change.Revision)
}
c.indexes[change.AttemptID] = change.Index + 1
}
if change.ResultSeq != 0 && (change.ResultSeq > c.coverage || (change.ResultKind != "message/complete" && change.ResultKind != "message/interrupted")) {
return fmt.Errorf("settlement_not_committed at revision %d", change.Revision)
}
if change.ResultSeq != 0 {
message := c.attempts[change.AttemptID]
recorded, known := c.results[message]
if message == "" || e == nil || message != e.MessageID || known && recorded != change.ResultSeq {
return fmt.Errorf("settlement_identity_mismatch at revision %d", change.Revision)
}
}
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action != "begin" {
delete(c.indexes, e.StreamAttempt.ID)
delete(c.attempts, e.StreamAttempt.ID)
}
if e != nil && (e.Kind == "text" || e.Kind == "reasoning") {
c.deltas[e.Kind] += e.Text
}
c.revision = change.Revision
}
return nil
}
// legacyStreamProvider answers each turn from a script: a streamed reply, a
// tool-call-only round followed by a reply, or a stream that stalls until the
// request is cancelled.
type legacyStreamProvider struct {
mu sync.Mutex
mode string
toolSent bool
}
func (p *legacyStreamProvider) set(mode string) {
p.mu.Lock()
p.mode, p.toolSent = mode, false
p.mu.Unlock()
}
func (p *legacyStreamProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
mode, toolRound := p.mode, p.mode == "tool" && !p.toolSent
if toolRound {
p.toolSent = true
}
p.mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
send := func(delta string) {
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{`+delta+`}}]}`+"\n\n")
flusher.Flush()
}
switch {
case toolRound:
send(`"tool_calls":[{"index":0,"id":"call_read","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"note.txt\"}"}}]`)
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n")
case mode == "stall":
send(`"reasoning_content":"pondering"`)
<-r.Context().Done()
return
default:
for _, chunk := range []string{`"reasoning_content":"weigh "`, `"reasoning_content":"it"`, `"content":"stream"`, `"content":"ed"`} {
send(chunk)
time.Sleep(20 * time.Millisecond)
}
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}
type legacyFollowTurn struct{ name, mode, reasoning, text string }
func TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts(t *testing.T) {
reply := legacyFollowTurn{"streamed reply", "stream", "weigh it", "streamed"}
for _, first := range []legacyFollowTurn{
reply,
{"tool-call-only round", "tool", "weigh it", "streamed"},
{"cancelled round", "stall", "pondering", ""},
} {
t.Run(first.name, func(t *testing.T) { followLegacyTopicTurns(t, first, reply) })
}
}
func followLegacyTopicTurns(t *testing.T, turns ...legacyFollowTurn) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
script := &legacyStreamProvider{}
stub := httptest.NewServer(script)
t.Cleanup(stub.Close)
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{{Name: "test", Kind: "openai", BaseURL: stub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "note.txt"), []byte("note"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(root, "Project"); err != nil {
t.Fatal(err)
}
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := writeTopicSession(t, dir, "legacy.jsonl", "legacy-topic", "Legacy", root)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
t.Cleanup(func() { app.shutdown(context.Background()) })
events := newActivationEventRecorder(app)
ticket, err := app.StartTopicActivation(TopicActivationRequest{Scope: "project", WorkspaceRoot: root, TopicID: "legacy-topic", SessionPath: path, RequestID: "open"})
if err != nil {
t.Fatal(err)
}
if ev := events.waitFor(t, func(ev TopicActivationEvent) bool {
return ev.RequestID == "open" && (ev.Phase == "ready" || ev.Phase == "failed")
}); ev.Phase != "ready" {
t.Fatalf("activation failed: %+v", ev)
}
base, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, Close: true})
})
if base.StorageBackend != "legacy" {
t.Fatalf("fixture must follow a legacy-backed session, got %q", base.StorageBackend)
}
cursor := newRendererFollowCursor(base.Snapshot)
for _, turn := range turns {
script.set(turn.mode)
clear(cursor.deltas)
if err := app.SubmitToTab(ticket.TabID, turn.name); err != nil {
t.Fatal(err)
}
cancelled := false
deadline := time.Now().Add(15 * time.Second)
for done := false; !done; {
if time.Now().After(deadline) {
t.Fatalf("%s did not finish", turn.name)
}
resp, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, AfterRevision: cursor.revision})
if err != nil {
t.Fatal(err)
}
if resp.ResetRequired {
t.Fatalf("%s: follower was reset", turn.name)
}
if err := cursor.accept(resp.Changes); err != nil {
t.Fatalf("%s: renderer would discard the follow and re-baseline: %v", turn.name, err)
}
for _, change := range resp.Changes {
done = done || change.Event != nil && change.Event.Kind == "turn_done"
}
if turn.mode == "stall" && !cancelled && cursor.deltas["reasoning"] != "" {
app.CancelTab(ticket.TabID)
cancelled = true
}
}
if cursor.deltas["reasoning"] != turn.reasoning || cursor.deltas["text"] != turn.text {
t.Fatalf("%s: renderer received deltas %+v", turn.name, cursor.deltas)
}
}
}
+81 -59
View File
@@ -68,14 +68,15 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
return out
}
base, err := workspaceBaseFromRoot(workspaceRoot)
repo, err := workspaceRepo(workspaceRoot, ctrl)
if err != nil {
out.GitAvailable = false
out.GitErr = err.Error()
return out
}
base := repo.Dir
out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
out.GitBranch, _ = workspaceGitBranchContext(ctx, repo)
changes := map[string]*workspaceChangeAccumulator{}
add := func(path string) *workspaceChangeAccumulator {
@@ -108,7 +109,7 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
}
}
gitEntries, gitErr := workspaceGitStatusContext(ctx, base)
gitEntries, gitErr := workspaceGitStatusContext(ctx, repo)
if gitErr != nil {
out.GitAvailable = false
out.GitErr = gitErr.Error()
@@ -153,7 +154,7 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
return strings.ToLower(a.Path) < strings.ToLower(b.Path)
})
if out.GitAvailable {
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, repo, untracked)
}
return out
}
@@ -173,13 +174,13 @@ func (a *App) workspaceChangesTarget(tabID string) (string, control.SessionAPI,
return tab.WorkspaceRoot, tab.Ctrl, true
}
func (a *App) workspaceBaseForTab(tabID string) (string, error) {
func (a *App) workspaceRepoForTab(tabID string) (gitcmd.Repo, error) {
tabID = strings.TrimSpace(tabID)
workspaceRoot, _, ok := a.workspaceChangesTarget(tabID)
workspaceRoot, ctrl, ok := a.workspaceChangesTarget(tabID)
if !ok {
return "", fmt.Errorf("tab %q not found", tabID)
return gitcmd.Repo{}, fmt.Errorf("tab %q not found", tabID)
}
return workspaceBaseFromRoot(workspaceRoot)
return workspaceRepo(workspaceRoot, ctrl)
}
// WorkspaceChangeDetail returns the current patch for one file in the
@@ -191,10 +192,11 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
if !ok {
return WorkspaceChangeDetailView{}, fmt.Errorf("tab %q not found", tabID)
}
base, err := workspaceBaseFromRoot(workspaceRoot)
repo, err := workspaceRepo(workspaceRoot, ctrl)
if err != nil {
return WorkspaceChangeDetailView{}, err
}
base := repo.Dir
rel := normalizeWorkspaceRelPath(base, path)
if rel == "" {
return WorkspaceChangeDetailView{}, os.ErrInvalid
@@ -206,7 +208,7 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
return WorkspaceChangeDetailView{}, os.ErrInvalid
}
if detail, found := workspaceGitChangeDetail(base, rel); found {
if detail, found := workspaceGitChangeDetail(repo, rel); found {
return detail, nil
}
if ctrl != nil {
@@ -217,8 +219,9 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
return WorkspaceChangeDetailView{}, nil
}
func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool) {
entries, err := workspaceGitStatus(base)
func workspaceGitChangeDetail(repo gitcmd.Repo, rel string) (WorkspaceChangeDetailView, bool) {
base := repo.Dir
entries, err := workspaceGitStatus(repo)
if err != nil {
return WorkspaceChangeDetailView{}, false
}
@@ -235,7 +238,7 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
// Untracked files are omitted by git diff. In an unborn repository HEAD is
// absent as well, so synthesize the same create/delete patch from disk.
if entry.Status == "??" || !workspaceGitHasHead(base) {
if entry.Status == "??" || !workspaceGitHasHead(repo) {
detail, err := workspaceCheckpointChangeDetail(base, rel, nil)
if err != nil {
return WorkspaceChangeDetailView{}, false
@@ -248,7 +251,7 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
if entry.OldPath != "" && entry.OldPath != rel {
args = append(args, filepath.FromSlash(entry.OldPath))
}
raw, truncated, err := workspaceGitDiffOutput(args...)
raw, truncated, err := workspaceGitDiffOutput(repo, args...)
if err != nil {
return WorkspaceChangeDetailView{}, false
}
@@ -264,12 +267,12 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
return WorkspaceChangeDetailView{Diff: &patch, Source: "git", Added: added, Removed: removed, Binary: binary}, true
}
func workspaceGitHasHead(base string) bool {
return workspaceGit("-C", base, "rev-parse", "--verify", "HEAD").Run() == nil
func workspaceGitHasHead(repo gitcmd.Repo) bool {
return workspaceGit(repo, "-C", repo.Dir, "rev-parse", "--verify", "HEAD").Run() == nil
}
func workspaceGitDiffOutput(args ...string) ([]byte, bool, error) {
cmd := workspaceGit(args...)
func workspaceGitDiffOutput(repo gitcmd.Repo, args ...string) ([]byte, bool, error) {
cmd := workspaceGit(repo, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, false, err
@@ -381,43 +384,44 @@ func tallyUnifiedPatch(patch string) (added, removed int) {
return added, removed
}
// workspaceGit builds a console-hidden git probe. gitcmd supplies the shared
// invocation baseline: CREATE_NO_WINDOW so git's own children inherit the
// invisible console, and the config overrides that keep a probe from spawning a
// background daemon that opens a console of its own (#3906).
func workspaceGit(args ...string) *exec.Cmd {
return workspaceGitCommand(context.Background(), args...)
// workspaceGit builds a console-hidden git probe against repo's pinned
// identity. gitcmd supplies the shared invocation baseline: CREATE_NO_WINDOW so
// git's own children inherit the invisible console, and the config overrides
// that keep a probe from spawning a background daemon (#3906).
func workspaceGit(repo gitcmd.Repo, args ...string) *exec.Cmd {
return workspaceGitCommand(context.Background(), repo, args...)
}
func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
return gitcmd.Command(ctx, "", args...)
func workspaceGitCommand(ctx context.Context, repo gitcmd.Repo, args ...string) *exec.Cmd {
return repo.Command(ctx, args...)
}
func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
func workspaceGitOutputWithTimeout(timeout time.Duration, repo gitcmd.Repo, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return workspaceGitCommand(ctx, args...).Output()
return workspaceGitCommand(ctx, repo, args...).Output()
}
func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
func workspaceGitStatus(repo gitcmd.Repo) ([]gitStatusEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return workspaceGitStatusContext(ctx, base)
return workspaceGitStatusContext(ctx, repo)
}
func workspaceGitStatusContext(ctx context.Context, base string) ([]gitStatusEntry, error) {
func workspaceGitStatusContext(ctx context.Context, repo gitcmd.Repo) ([]gitStatusEntry, error) {
base := repo.Dir
// Git's porcelain paths are repository-relative even when -C points at a
// subdirectory. Derive the textual repository prefix from Git itself instead
// of comparing absolute paths: Windows may spell the same directory once as
// an 8.3 path and once as a long path, which makes filepath.Rel reject every
// otherwise valid status entry.
prefixCmd := workspaceGitCommand(ctx, "-C", base, "rev-parse", "--show-prefix")
prefixCmd := workspaceGitCommand(ctx, repo, "-C", base, "rev-parse", "--show-prefix")
prefixRaw, err := prefixCmd.Output()
if err != nil {
return nil, err
}
prefix := strings.TrimSpace(string(prefixRaw))
cmd := workspaceGitCommand(ctx, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
cmd := workspaceGitCommand(ctx, repo, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
raw, err := cmd.Output()
if err != nil {
return nil, err
@@ -492,7 +496,7 @@ func workspaceRelPathFromGitPrefix(base, prefix, path string) string {
// metadata is less harmful than blocking tab activation or hydration. Workflows
// that need an immediate git read, such as WorkspaceChanges, should call
// workspaceGitBranch directly.
func workspaceGitBranchForMeta(base string) string {
func workspaceGitBranchForMeta(base string, repo gitcmd.Repo) string {
key := filepath.Clean(base)
now := time.Now()
@@ -507,7 +511,7 @@ func workspaceGitBranchForMeta(base string) string {
cached.request = new(int)
workspaceGitBranchCache.entries[key] = cached
workspaceGitBranchCache.Unlock()
go refreshWorkspaceGitBranchForMeta(key, base, cached.request)
go refreshWorkspaceGitBranchForMeta(key, base, repo, cached.request)
return branch
}
@@ -519,11 +523,13 @@ func workspaceGitBranchForMeta(base string) string {
}
workspaceGitBranchCache.Unlock()
go refreshWorkspaceGitBranchForMeta(key, base, request)
go refreshWorkspaceGitBranchForMeta(key, base, repo, request)
return ""
}
func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
// refreshWorkspaceGitBranchForMeta reads through repo, the tab session's
// identity; a tab with no session yet resolves base as opening it would.
func refreshWorkspaceGitBranchForMeta(key, base string, repo gitcmd.Repo, request *int) {
branch := ""
// Store via defer so the refreshing flag is always cleared, even when the
// probe panics or exits the goroutine early; otherwise the entry would stay
@@ -545,21 +551,24 @@ func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
}()
branch = workspaceGitBranchForMetaProbe(base)
if repo.Dir == "" {
repo = openWorkspaceRepo(base)
}
branch = workspaceGitBranchForMetaProbe(repo)
}
// workspaceGitBranch returns the current git branch name for the repo rooted
// at base, or an empty string when base is not inside a git repository or when
// git is unavailable.
func workspaceGitBranch(base string) string {
// workspaceGitBranch returns the current git branch name of repo, or an empty
// string when it names no git repository or git is unavailable.
func workspaceGitBranch(repo gitcmd.Repo) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
branch, _ := workspaceGitBranchContext(ctx, base)
branch, _ := workspaceGitBranchContext(ctx, repo)
return branch
}
func workspaceGitBranchContext(ctx context.Context, base string) (string, error) {
raw, err := workspaceGitCommand(ctx, "-C", base, "branch", "--show-current").Output()
func workspaceGitBranchContext(ctx context.Context, repo gitcmd.Repo) (string, error) {
base := repo.Dir
raw, err := workspaceGitCommand(ctx, repo, "-C", base, "branch", "--show-current").Output()
if err != nil {
return "", err
}
@@ -567,7 +576,7 @@ func workspaceGitBranchContext(ctx context.Context, base string) (string, error)
return branch, nil
}
raw, err = workspaceGitCommand(ctx, "-C", base, "rev-parse", "--short", "HEAD").Output()
raw, err = workspaceGitCommand(ctx, repo, "-C", base, "rev-parse", "--short", "HEAD").Output()
if err != nil {
return "", err
}
@@ -580,19 +589,19 @@ func workspaceGitBranchContext(ctx context.Context, base string) (string, error)
// GitBranches returns all local git branches for the active workspace's repo.
func (a *App) GitBranches() ([]string, error) {
base, err := a.activeWorkspaceBase()
repo, err := a.workspaceRepoForTab("")
if err != nil {
return nil, err
}
return workspaceLocalBranches(base)
return workspaceLocalBranches(repo)
}
func (a *App) GitCheckout(branch string) error {
base, err := a.activeWorkspaceBase()
repo, err := a.workspaceRepoForTab("")
if err != nil {
return err
}
return workspaceCheckoutBranch(base, branch, false)
return workspaceCheckoutBranch(repo, branch, false)
}
const gitRefInvalidChars = " ~^:?*[\\" + "\t\n"
@@ -607,11 +616,11 @@ func validGitBranchName(name string) bool {
}
func (a *App) GitCreateBranch(name string) error {
base, err := a.activeWorkspaceBase()
repo, err := a.workspaceRepoForTab("")
if err != nil {
return err
}
return workspaceCheckoutBranch(base, name, true)
return workspaceCheckoutBranch(repo, name, true)
}
type GitCommitView struct {
@@ -627,17 +636,18 @@ type GitCommitDetailView struct {
}
func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, error) {
base, err := a.workspaceBaseForTab(tabID)
repo, err := a.workspaceRepoForTab(tabID)
if err != nil {
return nil, err
}
base := repo.Dir
args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
if path != "" {
args = append(args, "--", path)
}
cmd := workspaceGit(args...)
cmd := workspaceGit(repo, args...)
raw, err := cmd.Output()
if err != nil {
return nil, err
@@ -658,27 +668,28 @@ func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, e
}
func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (GitCommitDetailView, error) {
base, err := a.workspaceBaseForTab(tabID)
repo, err := a.workspaceRepoForTab(tabID)
if err != nil {
return GitCommitDetailView{}, err
}
base := repo.Dir
if path != "" {
// Single file diff
cmd := workspaceGit("-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
cmd := workspaceGit(repo, "-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
return GitCommitDetailView{}, notLocalOr(repo, hash, err)
}
diffStr := strings.TrimSpace(string(raw))
return GitCommitDetailView{Diff: &diffStr}, nil
}
// Project level: list of files changed
cmd := workspaceGit("-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-z", "-r", hash)
cmd := workspaceGit(repo, "-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-z", "-r", hash)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
return GitCommitDetailView{}, notLocalOr(repo, hash, err)
}
var files []string
@@ -689,3 +700,14 @@ func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (
}
return GitCommitDetailView{Files: files}, nil
}
// notLocalOr names a failure caused by objects a partial clone never fetched —
// host git does not fetch them — and passes any other failure through.
func notLocalOr(repo gitcmd.Repo, hash string, err error) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if repo.ObjectsMissing(ctx, hash, hash+"^") {
return fmt.Errorf("commit %s: %w", hash, gitcmd.ErrObjectNotLocal)
}
return err
}
+17 -14
View File
@@ -9,6 +9,8 @@ import (
"sync"
"testing"
"time"
"reasonix/internal/gitcmd"
)
// The probe must not spawn a background daemon that opens a console of its own
@@ -17,14 +19,15 @@ import (
// it a second time here only guaranteed this test would break whenever the
// baseline gained an entry.
func TestWorkspaceGitDisablesDaemonSpawns(t *testing.T) {
cmd := workspaceGit("-C", "repo", "status", "--porcelain=v1")
for _, want := range []string{"core.fsmonitor=false", "maintenance.auto=false"} {
cmd := workspaceGit(gitcmd.Repo{}, "-C", "repo", "status", "--porcelain=v1")
for _, want := range []string{"core.fsmonitor=", "maintenance.auto=false"} {
if !hasGitConfigArg(cmd.Args, want) {
t.Fatalf("args = %v, want -c %s", cmd.Args, want)
}
}
if tail := cmd.Args[len(cmd.Args)-4:]; !slices.Equal(tail, []string{"-C", "repo", "status", "--porcelain=v1"}) {
t.Fatalf("args = %v, want the caller's arguments last", cmd.Args)
at := slices.Index(cmd.Args, "-C")
if at < 0 || !slices.Equal(cmd.Args[at:at+3], []string{"-C", "repo", "status"}) || cmd.Args[len(cmd.Args)-1] != "--porcelain=v1" {
t.Fatalf("args = %v, want the caller's arguments last and in order", cmd.Args)
}
if runtime.GOOS == "windows" && cmd.SysProcAttr == nil {
t.Fatal("workspaceGit must hide the console window on Windows")
@@ -61,7 +64,7 @@ func TestWorkspaceGitBranch(t *testing.T) {
runGit(t, "init")
runGit(t, "checkout", "-b", "feature/status")
if got := workspaceGitBranch(repo); got != "feature/status" {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/status" {
t.Fatalf("branch = %q, want feature/status", got)
}
}
@@ -87,11 +90,11 @@ func TestWorkspaceGitBranchReflectsImmediateCheckout(t *testing.T) {
runGit(t, "init")
runGit(t, "checkout", "-b", "feature/one")
if got := workspaceGitBranch(repo); got != "feature/one" {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/one" {
t.Fatalf("branch before checkout = %q, want feature/one", got)
}
runGit(t, "checkout", "-b", "feature/two")
if got := workspaceGitBranch(repo); got != "feature/two" {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/two" {
t.Fatalf("branch after checkout = %q, want feature/two", got)
}
}
@@ -125,13 +128,13 @@ func TestWorkspaceGitBranchDetachedHead(t *testing.T) {
short := gitOutput(t, "rev-parse", "--short", "HEAD")
runGit(t, "checkout", "--detach", "HEAD")
if got := workspaceGitBranch(repo); got != "@"+short {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "@"+short {
t.Fatalf("branch = %q, want @%s", got, short)
}
}
func TestWorkspaceGitBranchNonGitDirectory(t *testing.T) {
if got := workspaceGitBranch(t.TempDir()); got != "" {
if got := workspaceGitBranch(openWorkspaceRepo(t.TempDir())); got != "" {
t.Fatalf("branch = %q, want empty", got)
}
}
@@ -143,7 +146,7 @@ func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
release := make(chan struct{})
var releaseOnce sync.Once
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
workspaceGitBranchForMetaProbe = func(string) string {
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string {
close(started)
<-release
return "feature/async"
@@ -153,7 +156,7 @@ func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
workspaceGitBranchForMetaProbe = origProbe
}()
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe"); got != "" {
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe", gitcmd.Repo{}); got != "" {
t.Fatalf("cold branch = %q, want empty while async refresh runs", got)
}
// The probe is still blocked here, so returning is the deterministic proof
@@ -182,7 +185,7 @@ func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
release := make(chan struct{})
var releaseOnce sync.Once
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
workspaceGitBranchForMetaProbe = func(string) string {
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string {
close(started)
<-release
return "feature/fresh"
@@ -192,7 +195,7 @@ func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
workspaceGitBranchForMetaProbe = origProbe
}()
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe"); got != "feature/stale" {
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe", gitcmd.Repo{}); got != "feature/stale" {
t.Fatalf("stale branch = %q, want feature/stale", got)
}
// The probe is still blocked here, so returning the stale value proves that
@@ -218,7 +221,7 @@ func eventuallyBranchForMeta(t *testing.T, base, want string) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := workspaceGitBranchForMeta(base); got == want {
if got := workspaceGitBranchForMeta(base, gitcmd.Repo{}); got == want {
return
}
time.Sleep(10 * time.Millisecond)
+27 -22
View File
@@ -7,61 +7,63 @@ import (
"path/filepath"
"strings"
"time"
"reasonix/internal/gitcmd"
)
func (a *App) gitWorkspaceBaseForTab(tabID, expectedRoot string) (string, error) {
func (a *App) gitWorkspaceRepoForTab(tabID, expectedRoot string) (gitcmd.Repo, error) {
tabID = strings.TrimSpace(tabID)
if tabID == "" {
return "", fmt.Errorf("workspace tab is required")
return gitcmd.Repo{}, fmt.Errorf("workspace tab is required")
}
root, _, ok := a.workspaceChangesTarget(tabID)
root, ctrl, ok := a.workspaceChangesTarget(tabID)
if !ok || !filepath.IsAbs(root) {
return "", fmt.Errorf("workspace tab %q is unavailable", tabID)
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q is unavailable", tabID)
}
if !filepath.IsAbs(expectedRoot) || !sameProjectRoot(root, expectedRoot) {
return "", fmt.Errorf("workspace tab %q has changed project", tabID)
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q has changed project", tabID)
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return "", fmt.Errorf("workspace tab %q directory is unavailable", tabID)
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q directory is unavailable", tabID)
}
return workspaceBaseFromRoot(root)
return workspaceRepo(root, ctrl)
}
func (a *App) GitBranchesForTab(tabID, workspaceRoot string) ([]string, error) {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return nil, err
}
return workspaceLocalBranches(base)
return workspaceLocalBranches(repo)
}
func (a *App) GitCheckoutForTab(tabID, workspaceRoot, branch string) error {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return err
}
return workspaceCheckoutBranch(base, branch, false)
return workspaceCheckoutBranch(repo, branch, false)
}
func (a *App) GitCreateBranchForTab(tabID, workspaceRoot, name string) error {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return err
}
return workspaceCheckoutBranch(base, name, true)
return workspaceCheckoutBranch(repo, name, true)
}
// The launcher needs only Git totals, not session checkpoints or per-file views.
func (a *App) WorkspaceGitStatsForTab(tabID, workspaceRoot string) (WorkspaceChangesView, error) {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return WorkspaceChangesView{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
entries, err := workspaceGitStatusContext(ctx, base)
out.GitBranch, _ = workspaceGitBranchContext(ctx, repo)
entries, err := workspaceGitStatusContext(ctx, repo)
if err != nil {
out.GitAvailable, out.Incomplete, out.GitErr = false, true, err.Error()
return out, nil
@@ -72,19 +74,22 @@ func (a *App) WorkspaceGitStatsForTab(tabID, workspaceRoot string) (WorkspaceCha
untracked = append(untracked, entry.Path)
}
}
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, repo, untracked)
return out, nil
}
func workspaceLocalBranches(base string) ([]string, error) {
raw, err := workspaceGitOutputWithTimeout(3*time.Second, "-C", base, "branch", "--format=%(refname:short)")
func workspaceLocalBranches(repo gitcmd.Repo) ([]string, error) {
raw, err := workspaceGitOutputWithTimeout(3*time.Second, repo, "-C", repo.Dir, "branch", "--format=%(refname:short)")
if err != nil {
return nil, err
}
return append([]string{}, strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })...), nil
}
func workspaceCheckoutBranch(base, name string, create bool) error {
// workspaceCheckoutBranch switches repo's branch. Submodules are not updated
// (gitcmd pins submodule.recurse off for checkout).
func workspaceCheckoutBranch(repo gitcmd.Repo, name string, create bool) error {
base := repo.Dir
name = strings.TrimSpace(name)
if !validGitBranchName(name) {
return fmt.Errorf("invalid branch name %q", name)
@@ -96,11 +101,11 @@ func workspaceCheckoutBranch(base, name string, create bool) error {
args = append(args, name)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := workspaceGitCommand(ctx, args...).CombinedOutput()
out, err := workspaceGitCommand(ctx, repo, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("git checkout: %w: %s", err, strings.TrimSpace(string(out)))
}
branch, _ := workspaceGitBranchContext(ctx, base)
branch, _ := workspaceGitBranchContext(ctx, repo)
workspaceGitBranchCache.Lock()
if branch == "" {
delete(workspaceGitBranchCache.entries, filepath.Clean(base))
+6 -3
View File
@@ -3,6 +3,8 @@ package main
import (
"path/filepath"
"testing"
"reasonix/internal/gitcmd"
)
func TestWorkspaceGitCheckoutInvalidatesInFlightBranchProbe(t *testing.T) {
@@ -14,11 +16,12 @@ func TestWorkspaceGitCheckoutInvalidatesInFlightBranchProbe(t *testing.T) {
workspaceGitBranchCache.Unlock()
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
original := workspaceGitBranchForMetaProbe
workspaceGitBranchForMetaProbe = func(string) string { close(started); <-release; return "main" }
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string { close(started); <-release; return "main" }
defer func() { workspaceGitBranchForMetaProbe = original }()
go func() { refreshWorkspaceGitBranchForMeta(key, base, request); close(done) }()
repo := openWorkspaceRepo(base)
go func() { refreshWorkspaceGitBranchForMeta(key, base, repo, request); close(done) }()
<-started
err := workspaceCheckoutBranch(base, "shared", false)
err := workspaceCheckoutBranch(repo, "shared", false)
close(release)
<-done
if err != nil {
+7 -5
View File
@@ -7,6 +7,8 @@ import (
"slices"
"strings"
"testing"
"reasonix/internal/gitcmd"
)
func gitScopeRepo(t *testing.T) string {
@@ -17,7 +19,7 @@ func gitScopeRepo(t *testing.T) string {
{"-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "init"},
{"branch", "shared"},
} {
if out, err := workspaceGit(append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
if out, err := gitcmd.Command(context.Background(), root, args...).CombinedOutput(); err != nil {
t.Fatalf("git fixture: %v: %s", err, out)
}
}
@@ -34,7 +36,7 @@ func TestWorkspaceGitBranchScope(t *testing.T) {
if err != nil || !slices.Contains(list, "shared") {
t.Fatalf("branches: %v, %v", list, err)
}
base, err := a.gitWorkspaceBaseForTab("a", rootA)
base, err := a.gitWorkspaceRepoForTab("a", rootA)
if err != nil {
t.Fatal(err)
}
@@ -45,13 +47,13 @@ func TestWorkspaceGitBranchScope(t *testing.T) {
if err := workspaceCheckoutBranch(base, "shared", false); err != nil {
t.Fatal(err)
}
if workspaceGitBranch(rootA) != "shared" || workspaceGitBranch(rootB) != "main" {
if workspaceGitBranch(openWorkspaceRepo(rootA)) != "shared" || workspaceGitBranch(openWorkspaceRepo(rootB)) != "main" {
t.Fatal("checkout changed the replacement workspace")
}
if err := a.GitCreateBranchForTab("b", rootB, "new-branch"); err != nil {
t.Fatal(err)
}
if workspaceGitBranch(rootA) != "shared" || workspaceGitBranch(rootB) != "new-branch" {
if workspaceGitBranch(openWorkspaceRepo(rootA)) != "shared" || workspaceGitBranch(openWorkspaceRepo(rootB)) != "new-branch" {
t.Fatal("create crossed project boundaries")
}
if err := a.GitCheckoutForTab("a", rootB, "--detach"); err == nil {
@@ -117,7 +119,7 @@ func TestWorkspaceTallyBudgetAndFileKinds(t *testing.T) {
for i := range paths {
paths[i] = "large"
}
added, _, partial := workspaceGitDiffTally(context.Background(), base, paths)
added, _, partial := workspaceGitDiffTally(context.Background(), openWorkspaceRepo(base), paths)
if added != workspaceDiffTallyReadLimit/2 || !partial {
t.Fatalf("aggregate budget: %d, %v", added, partial)
}
+6 -3
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strconv"
"strings"
"reasonix/internal/gitcmd"
)
const (
@@ -15,11 +17,12 @@ const (
workspaceDiffTallyReadLimit = 16 << 20
)
func workspaceGitDiffTally(ctx context.Context, base string, untracked []string) (added, removed int, incomplete bool) {
raw, err := workspaceGitCommand(ctx, "-C", base, "diff", "--numstat", "--no-textconv", "HEAD", "--", ".").Output()
func workspaceGitDiffTally(ctx context.Context, repo gitcmd.Repo, untracked []string) (added, removed int, incomplete bool) {
base := repo.Dir
raw, err := workspaceGitCommand(ctx, repo, "-C", base, "diff", "--numstat", "--no-textconv", "HEAD", "--", ".").Output()
if err != nil && ctx.Err() == nil {
incomplete = true
raw, err = workspaceGitCommand(ctx, "-C", base, "diff", "--numstat", "--no-textconv", "--", ".").Output()
raw, err = workspaceGitCommand(ctx, repo, "-C", base, "diff", "--numstat", "--no-textconv", "--", ".").Output()
}
if err != nil {
return 0, 0, true
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"reasonix/internal/gitcmd"
)
// partialClone returns a blob-filtered clone of a three-commit repository, so
// the blobs of every commit but the checked-out one are absent locally.
func partialClone(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
src := t.TempDir()
run := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run(src, "init", "-q")
for _, body := range []string{"1\n", "2\n", "3\n"} {
if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
run(src, "add", "a.txt")
run(src, "commit", "-q", "-m", body)
}
run(src, "config", "uploadpack.allowFilter", "true")
clone := filepath.Join(t.TempDir(), "partial")
run(src, "clone", "-q", "--filter=blob:none", "file://"+src, clone)
return clone
}
// A historical patch in a partial clone fails with an error naming the
// unfetched objects, which the commit-detail view can tell from other failures.
func TestCommitDetailInPartialCloneReportsObjectNotLocal(t *testing.T) {
clone := partialClone(t)
out, err := workspaceGit(openWorkspaceRepo(clone), "-C", clone, "show", "--pretty=format:", "--patch", "HEAD~1", "--", "a.txt").Output()
if err == nil {
t.Fatalf("show of an unfetched commit succeeded: %s", out)
}
if got := notLocalOr(openWorkspaceRepo(clone), "HEAD~1", err); !errors.Is(got, gitcmd.ErrObjectNotLocal) {
t.Fatalf("notLocalOr = %v, want ErrObjectNotLocal", got)
}
other := errors.New("other")
if got := notLocalOr(openWorkspaceRepo(clone), "no-such-rev", other); !errors.Is(got, other) {
t.Fatalf("notLocalOr(no-such-rev) = %v, want the original error passed through", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"path/filepath"
"time"
"reasonix/internal/control"
"reasonix/internal/gitcmd"
)
const workspaceRepoOpenLimit = 3 * time.Second
// sessionWorkspaceRepo is the git identity ctrl's session resolved for base
// when it opened, and whether that session holds one for base at all.
func sessionWorkspaceRepo(ctrl control.SessionAPI, base string) (gitcmd.Repo, bool) {
c, ok := ctrl.(interface{ WorkspaceRepo() gitcmd.Repo })
if !ok {
return gitcmd.Repo{}, false
}
repo := c.WorkspaceRepo()
return repo, repo.Dir != "" && filepath.Clean(repo.Dir) == filepath.Clean(base)
}
// workspaceRepo is the identity host git reads root through: the one its
// session opened with, or, for a root no session holds yet, the one it
// resolves to now, as opening it would.
func workspaceRepo(root string, ctrl control.SessionAPI) (gitcmd.Repo, error) {
base, err := workspaceBaseFromRoot(root)
if err != nil {
return gitcmd.Repo{}, err
}
if repo, ok := sessionWorkspaceRepo(ctrl, base); ok {
return repo, nil
}
return openWorkspaceRepo(base), nil
}
func openWorkspaceRepo(base string) gitcmd.Repo {
ctx, cancel := context.WithTimeout(context.Background(), workspaceRepoOpenLimit)
defer cancel()
repo, err := gitcmd.Open(ctx, base)
if err != nil {
return gitcmd.Repo{Dir: base}
}
return repo
}
// workspaceRepoForRoot is root's identity as a tab session holding it opened
// it, or as opening it now would.
func (a *App) workspaceRepoForRoot(root string) (gitcmd.Repo, error) {
var ctrl control.SessionAPI
a.mu.RLock()
for _, tab := range a.tabs {
if tab.Ctrl != nil && sameProjectRoot(tab.WorkspaceRoot, root) {
ctrl = tab.Ctrl
break
}
}
a.mu.RUnlock()
return workspaceRepo(root, ctrl)
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/gitcmd"
)
// The changes panel reads a tab through the identity its session resolved when
// it opened, even after the workspace — a subdirectory of that repository —
// gains its own .git whose local config names a filter.
func TestWorkspaceChangesReadTheSessionRepository(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("payload script is POSIX shell")
}
mono := t.TempDir()
pkg := filepath.Join(mono, "pkg")
git := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
if err := os.MkdirAll(pkg, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkg, "f.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatal(err)
}
git(mono, "init", "-q")
git(mono, "add", ".")
git(mono, "commit", "-qm", "base")
repo, err := gitcmd.Open(context.Background(), pkg)
if err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{Sink: event.Discard, WorkspaceRoot: pkg, WorkspaceRepo: repo})
a := &App{tabs: map[string]*WorkspaceTab{"a": {WorkspaceRoot: pkg, Ctrl: ctrl}}}
marker := filepath.Join(t.TempDir(), "executed")
payload := filepath.Join(t.TempDir(), "payload.sh")
if err := os.WriteFile(payload, []byte("#!/bin/sh\necho ran >> '"+marker+"'\ncat\n"), 0o755); err != nil {
t.Fatal(err)
}
git(pkg, "init", "-q")
git(pkg, "config", "filter.pwn.clean", payload)
if err := os.WriteFile(filepath.Join(pkg, ".gitattributes"), []byte("f.txt filter=pwn\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkg, "f.txt"), []byte("b\n"), 0o644); err != nil {
t.Fatal(err)
}
view := a.WorkspaceChanges("a")
var modified bool
for _, f := range view.Files {
modified = modified || (f.Path == "f.txt" && f.GitStatus == "M")
}
if !view.GitAvailable || !modified || view.Removed != 1 {
t.Fatalf("changes = %+v, want f.txt modified against the enclosing repository", view)
}
if _, err := a.WorkspaceChangeDetail("a", "f.txt"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("the changes panel ran the nested repository's filter")
}
}
+18 -4
View File
@@ -159,11 +159,25 @@ entirely of non-ASCII characters get a stable hash suffix such as
generated environment variable remains valid; for example, `9router` becomes
`CUSTOM_9ROUTER_API_KEY`.
The CLI custom-provider wizard uses this rule while building its draft. For example
The CLI custom-provider wizard uses this rule for its draft name. For example
`https://token.sensenova.cn/v1` creates provider name
`custom-token-sensenova-cn`, whose default key env is
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`; commit then switches the saved connection
to its newly allocated private slot.
`custom-token-sensenova-cn`, whose draft key env is
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`. Pressing Enter at the variable-name
prompt keeps that draft only until the key is saved; the saved connection then
uses a newly allocated private slot.
A variable name you type at that prompt in `reasonix setup` is kept, so scripts
can refer to a stable name, as long as saving under it changes nothing another
connection reads: no other provider, bot or remote-host setting in the config
reads it, the global `.env` holds no value (or cleared marker) for it, and the
environment Reasonix runs in does not already set it. Otherwise the wizard says
what holds the name and asks again; Enter falls back to a
private slot. If the name is claimed between the prompt and saving, the save is
refused and nothing is written.
Saving a new key later for a provider that uses a typed name moves that
provider to a private slot; the old value stays under the typed name. Rotating
in place is tracked in #11010.
Existing configs are not rewritten on upgrade. If an old custom provider already
uses `CUSTOM_API_KEY`, it will keep working with that key. If several old custom
+12 -2
View File
@@ -129,8 +129,18 @@ env 名,例如 `LOCAL_GATEWAY_API_KEY`;如果名称全部由中文等非 ASC
都共用 `CUSTOM_API_KEY`。如果名称以数字开头,则会添加 `CUSTOM_` 前缀以保证生成的
环境变量名合法;例如 `9router` 会生成 `CUSTOM_9ROUTER_API_KEY`。
CLI 的自定义 provider 向导在构建草稿时仍会先根据 base URL 生成 provider 名称,再套用
同一规则;提交时会把保存后的连接切换到新分配的独立槽位。
CLI 的自定义 provider 向导会先根据 base URL 生成 provider 名称,再套用同一规则得到草稿
变量名。在变量名提示处直接回车时,这个草稿名只在保存 key 之前使用;保存后连接会切换到
新分配的独立槽位。
在 `reasonix setup` 的这个提示处手动输入的变量名会被保留,方便脚本引用固定名称,前提是
写入它不会改变其他读取方拿到的值:配置里没有其他 provider、机器人或远程主机设置读取它,
全局 `.env` 里没有它的值(或清空标记),Reasonix 运行的环境变量里也没有设置它。否则向导
会说明是什么占用了它,并请你重新输入;直接回车则改用独立槽位。如果在提示之后、保存之前这个名字被占用,保存会被拒绝,
不写入任何内容。
之后再为使用手动变量名的 provider 保存新 key 时,该 provider 会切换到独立槽位,旧值仍留在
手动输入的变量名下。原地轮换见 #11010。
升级时不会自动改写已有配置。旧配置中已经使用 `CUSTOM_API_KEY` 的自定义 provider 会继续
读取这个 key。若多个旧自定义 provider 已经意外共用了 `CUSTOM_API_KEY`,重新保存每个
+95 -2
View File
@@ -246,8 +246,30 @@ the terminal; stop it with Ctrl-C.
An explicit `reasonix web --auth none` disables the default token and should be
used only when the listener is intentionally trusted. `reasonix serve` keeps its
backward-compatible, config-driven `auth_mode = "none"` default on
`127.0.0.1:8787`. If you bind Serve outside loopback, expose it through a tunnel,
or put it behind a reverse proxy, enable authentication before sharing the URL:
`127.0.0.1:8787`.
Without authentication (`auth_mode = "none"`, the `serve` default) reads stay
open on the listener, but every state-changing request, approvals included,
needs the launch token:
- Serve writes the token to a 0600 file under `<Reasonix home>/remote/` and
prints only its path next to an `approvals:` link; append
`#token=<file contents>` to open it in a browser. A managed launch with
`--token-file` names that file instead. Token mode prints its `share:` link
the same way.
- Send it as `Authorization: Bearer <token>`, or open the link once so the page
sets its cookie. Without it the request answers 403 `launch_token_required`.
- Prefer `--token-file` over `--token`: argv is visible to other processes,
sandboxed ones included. A plaintext `[serve].token` in the global
config is readable from inside the sandbox; keep the secret in a file.
- On macOS and Linux the OS sandbox denies the remote state directory and any
`--token-file`. Windows has no bash sandbox, so there nothing keeps an agent
command from reading the file.
- `[serve]` is read from the user config only; a project `reasonix.toml`
cannot set it.
If you bind Serve outside loopback, expose it through a tunnel, or put it behind
a reverse proxy, enable authentication before sharing the URL:
```bash
reasonix serve --auth token
@@ -723,6 +745,77 @@ it has no OS-level shell sandbox, and denying the current user would also deny
the host settings process. Project `.env` files keep their existing
workspace-scoped behavior.
**Git metadata is host-protected.** Inside the bash sandbox, the Git
configuration and hooks of the workspace repository stay read-only, because the
host's own git reads them.
The repository is the one git itself discovers from each writable root. A
`.git` file is followed to the gitdir it names the way git resolves it,
relative to the file with symlinks followed, so the protection lands on what
git will read. Protected:
- `.git` itself, the gitdir, the common dir and every symlink on the way to
them: none can be removed, renamed or replaced by a symlink.
- `config`, `config.worktree`, `commondir` and `hooks/` of the gitdir and of
the common dir.
- `config`, `config.worktree` and `commondir` of each existing `worktrees/*`
entry, and `config` and `config.worktree` of entries created later.
- `config`, `config.worktree`, `commondir` and `hooks/` of each submodule
gitdir under `modules/`, existing or created later.
Everything else under `.git` (objects, refs, index, logs, lock files) stays
writable, so add, commit, branch, checkout, merge, rebase, stash, tag and
worktree creation keep working. These operations change:
| Operation | In the sandbox |
| --- | --- |
| `git config` without `--global`, `git remote add` / `set-url`, `git branch -m`, `git submodule init`, `git submodule update --init`, `git sparse-checkout init`, `git maintenance register`, `git init` in an existing repository | Fails |
| Installing a hook into `.git/hooks` | Fails |
| `git worktree remove` / `prune` of a linked worktree that existed before the command | Fails; one added in the same command can be removed |
| Cloning a new submodule (`git submodule add`, `git submodule update` for one not yet cloned) | Fails on macOS; on Linux the clone lands but its config entry does not |
| `git branch --set-upstream-to`, `git checkout --track`, `git push -u` | Reports the refused write but exits 0; no upstream is recorded |
To add or initialise submodules, run it outside the sandbox (in a terminal, or through an approved danger-full-access retry); updating submodules that are
already cloned works inside it.
When a command's output names one of these paths, the bash result says so with
`sandbox.git_metadata_protected`, also when git exited 0. `additional_write_dirs` cannot grant it.
A protected file that already has another hard link refuses every confined
command with `sandbox.git_metadata_linked`, because a write through the other
name would reach it; the user removes that link outside the sandbox.
Limits:
- On Linux, bubblewrap can only mount over paths that exist and cannot pin a
symlink. Creating an absent `commondir`, `config.worktree` or hooks
directory, or swapping a symlink on the `gitdir:` path, is not stopped there.
- Closing that on Linux is the host's side: its own git has to pin its git and
common directories instead of rediscovering them.
- Existing worktree and submodule gitdirs get exact rules, up to 128 each on
macOS and 512 on Linux.
- On macOS, patterns cover the rest and gitdirs created later. They skip
`refs/` and `logs/`, so a branch or tag named `config` or `hooks` stays
writable, but a new submodule named `hooks` is protected whole.
- On macOS with more than 128 worktrees, `git worktree add` fails; past 128
submodules each command starts about 0.1 s slower.
- On Linux, past 512 gitdirs the whole `worktrees/` or `modules/` directory is
mounted read-only, and a command can cause that by planting `HEAD` files.
A command can likewise plant a submodule gitdir with a hard-linked config,
after which every confined command is refused until the user removes it.
- A repository created by a sandboxed command is protected from the next
command on.
- A `.git` made unrecognisable to git (for example a corrupted `HEAD`) sends
git's discovery further up.
- A repository nested in the workspace that is not a submodule gitdir under
`modules/` is not protected, including one a command creates and records as
a gitlink.
- The host can run such a repository's configuration through its gitlink
unless the host's own git excludes it.
- Hooks that `core.hooksPath` points outside `.git`, and files `include.path`
names, are ordinary workspace files.
- Windows has no shell sandbox, so none of this is enforced there.
**Session-private temporary directory.** Within one logical chat session, Bash
commands share a private temporary directory so consecutive calls can exchange
files through `$TMPDIR` (and, on Linux under bubblewrap, through literal
+65 -2
View File
@@ -220,8 +220,17 @@ reasonix web
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
请先开启认证再分享 URL:
`auth_mode = "none"`。
未开启认证时读取接口保持开放,但所有会改变状态的请求(包括审批)都需要本次启动的令牌:
- serve 把令牌写进 `<Reasonix home>/remote/` 下权限 0600 的文件,终端只打印文件路径和 `approvals:` 链接;在链接后拼上 `#token=<文件内容>` 用浏览器打开。带 `--token-file` 的托管启动则打印该文件路径。token 模式的 `share:` 链接同样处理。
- 以 `Authorization: Bearer <token>` 发送,或打开一次链接让页面写入 Cookie;否则返回 403 `launch_token_required`。
- 优先用 `--token-file` 而不是 `--token`:命令行参数对其他进程可见,沙盒内的进程也能看到。全局配置里明文的 `[serve].token` 在沙盒内可读,密钥请放文件。
- macOS 和 Linux 的系统沙盒会拒绝读取该状态目录和 `--token-file`;Windows 没有 bash 沙盒,agent 命令可以读到该文件。
- `[serve]` 只从用户配置读取,项目里的 `reasonix.toml` 不能设置它。
如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,请先开启认证再分享 URL:
```bash
reasonix serve --auth token
@@ -580,6 +589,60 @@ Reasonix 始终会从工具子进程环境中移除已保存的 provider 与 bot
因为 Windows 没有 OS 级 Shell 沙箱,而拒绝当前用户也会拒绝宿主设置进程。项目
`.env` 仍保持现有的 workspace 范围行为。
**Git 元数据由宿主保护。**Bash 沙盒内,工作区仓库的 Git 配置和钩子保持只读,
因为宿主自己的 git 会读取它们。
受保护的仓库是 git 自身从每个可写根发现的那个。`.git` 是文件时,按 git 的方式解析
它指向的 gitdir(相对该文件、跟随符号链接),保护落在 git 实际读取的位置。受保护:
- `.git` 本身、gitdir、公共目录及通往它们的每个符号链接:都不能被删除、改名或替换成符号链接。
- gitdir 与公共目录中的 `config`、`config.worktree`、`commondir` 和 `hooks/`。
- 已有 `worktrees/*` 条目的 `config`、`config.worktree`、`commondir`,以及之后新建条目的
`config` 和 `config.worktree`。
- `modules/` 下每个子模块 gitdir(已有的和之后新建的)的 `config`、`config.worktree`、
`commondir` 和 `hooks/`。
`.git` 下其余内容(objects、refs、index、logs、锁文件)仍可写,因此 add、commit、
branch、checkout、merge、rebase、stash、tag 和创建 worktree 照常工作。以下操作的
行为会变:
| 操作 | 沙盒内 |
| --- | --- |
| 不带 `--global` 的 `git config`、`git remote add` / `set-url`、`git branch -m`、`git submodule init`、`git submodule update --init`、`git sparse-checkout init`、`git maintenance register`、对已有仓库执行 `git init` | 失败 |
| 向 `.git/hooks` 安装钩子 | 失败 |
| 对命令开始前已存在的 linked worktree 执行 `git worktree remove` / `prune` | 失败;同一条命令里新建的可以删除 |
| 克隆新的子模块(`git submodule add`,或对尚未克隆的子模块执行 `git submodule update`) | macOS 上失败;Linux 上克隆成功但配置条目写不进去 |
| `git branch --set-upstream-to`、`git checkout --track`、`git push -u` | 报告写入被拒但退出码为 0;不会记录上游 |
需要添加或初始化子模块时,在沙盒外运行(终端里,或经用户批准的 danger-full-access 重试);已克隆的子模块在沙盒内仍可更新。
命令输出里出现这些路径时,bash 结果会用 `sandbox.git_metadata_protected` 指明,git
退出码为 0 时也一样。`additional_write_dirs` 无法授权。
受保护文件如果已有另一个硬链接,所有沙盒命令都会以 `sandbox.git_metadata_linked` 被拒,
因为经另一个名字的写入会改到它;需要用户在沙盒外删掉那个链接。
限制:
- Linux 上 bubblewrap 只能挂载已存在的路径,也钉不住符号链接:新建尚不存在的
`commondir`、`config.worktree` 或钩子目录、替换 `gitdir:` 路径上的符号链接,在那里都拦不住。
- 在 Linux 上补这一点要靠宿主一侧:宿主自己的 git 固定其 git 目录与公共目录,而不是重新发现。
- 已有的 worktree 与子模块 gitdir 用精确规则,macOS 上各至多 128 个,Linux 上至多 512 个。
- macOS 上其余的以及之后新建的由模式覆盖。模式跳过 `refs/` 和 `logs/`,名为 `config` 或
`hooks` 的分支、标签仍可写,但新建的名为 `hooks` 的子模块会整个被保护。
- macOS 上 worktree 超过 128 个时 `git worktree add` 会失败;子模块超过 128 个时每条命令
启动约慢 0.1 秒。
- Linux 上超过 512 个 gitdir 时整个 `worktrees/` 或 `modules/` 以只读挂载,命令可以通过伪造
`HEAD` 文件触发这一点。命令也可以伪造一个配置带硬链接的子模块 gitdir,之后所有沙盒命令都会
被拒,直到用户删掉它。
- 沙盒命令新建的仓库从下一条命令起受保护。
- 被弄成 git 不认识的 `.git`(例如损坏的 `HEAD`)会让 git 继续向上查找。
- 工作区里嵌套的、不是 `modules/` 下子模块 gitdir 的仓库不受保护,包括命令自己建出来并
记录成 gitlink 的。
- 除非宿主自己的 git 排除它,宿主可能经由该 gitlink 执行它的配置。
- `core.hooksPath` 指向 `.git` 之外的钩子、`include.path` 引用的文件,都是普通工作区文件。
- Windows 没有 Bash 沙盒,以上都不生效。
**会话私有标准临时目录。**同一逻辑会话内的多条 Bash 命令共享一个私有临时目录,
因此连续调用可以通过 `$TMPDIR` 交换文件(在 Linux bubblewrap 下还可以通过字面
`/tmp`)。用户不需要设置:Reasonix 会自动为 Bash 和客户端托管的 ACP 终端注入
+36 -28
View File
@@ -8,7 +8,26 @@ record, checks its provenance and bytes, and requires one `release` approval
before creating the three tags and publishing. `recover` reuses the same sealed
files; it must not rebuild or sign them.
## Public-site recovery / 官网独立恢复
Rendering the notes credits people: each `refs` number that is a pull request
gains `by @author`; an issue gains `fixed in #M by @author` for each merged pull
request GitHub links as closing it. A closing Contributors list names those
humans once, in first-appearance order. Issues fixed by a direct push and bot
authors are left plain. The lookup needs a GitHub token (`GH_TOKEN`, else
`gh auth token` locally) and `issues: read` plus `pull-requests: read`.
Transient failures (5xx, rate limits, dropped connections) are retried with
backoff; one that persists, or an authorisation failure, fails the render with
a `release_credits.*` code instead of publishing notes without that credit. A
number that names a discussion or nothing stays plain with a warning.
The seal job renders the notes once into `evidence/release-notes.md` inside the
candidate payload and records it as `notes.renderedPath`, bound to
`notes.renderedSha256`. Promotion verifies the payload and publishes those
bytes without calling GitHub, so an author renamed, an issue newly linked to a
fix or an API outage after sealing cannot change or block what ships. A
candidate sealed before this has no `renderedPath` and is rendered offline from
its own source, as before.
## Public checks / 公开渠道检查
Candidate preparation and publication execute offline identity, resolver-output,
archive-integrity, atomic-tag, and publication-ledger contracts before expensive
@@ -20,34 +39,23 @@ a new release, but rejects HTTP errors, browser challenges, and malformed JSON.
另从 runner 以无凭据请求检查公开 Stable 清单;发新版前允许清单仍为旧版,但 HTTP 错误、浏览器挑战及
无效 JSON 必须失败。离线契约测试与在线可访问性检查相互独立。
For an already-published release whose website deployment or public verification
failed, dispatch **Recover release site** (`release-site-recovery.yml`) on
protected `main-v2` with `version=X.Y.Z`. It resolves all existing tags on protected
history, re-reads public package identities, and requires one `release` approval.
After approval it rechecks the public state, requires an existing matching release
event, and uses the same site-sync owner as full publication. No candidate artifact
is needed, so this remains usable after candidate payload expiry. It does not
build, sign, create tags, upload release assets, publish packages, or move pointers.
Missing packages or a missing release event require full publication recovery.
After every publisher succeeds, postflight verifies the tags, the CLI and
Desktop GitHub Releases, npm, the Desktop Stable manifest on `dl.reasonix.io`
and the Homebrew cask, then attaches `release-event.json`. Publication is
complete when those product surfaces are verified. A 90-day ledger records the
source/control SHA, observed surfaces, and the exact failed stage. On recovery
a proven newer Stable pointer is preserved, never a failed HTTP observation.
已发布版本若官网部署或公开验收失败,可在受保护 `main-v2` 上运行 **Recover release site**,输入
`version=X.Y.Z`。它校验三个已有标签属于受保护历史,重新读取公开包身份,并要求一次 `release`
审批。审批后再次核验公开状态及已有的匹配发布事件,复用完整发布的官网同步脚本。不依赖候选产物,
因此候选 payload 过期后仍可使用;不构建、不签名、不建标签、不上传安装包、不发布 npm、不移动指针。
缺少包或发布事件时,应使用完整发布恢复。
发布任务全部成功后,postflight 核验三个标签、CLI 与桌面端 GitHub Release、npm、`dl.reasonix.io`
上的桌面端 Stable 清单和 Homebrew cask,再附上 `release-event.json`;这些产品渠道全部核验通过即发布完成。
90 天台账保留产品/控制 SHA、已验证渠道及失败阶段。恢复时只有确证存在更新的 Stable 指针才予以保留,
HTTP 失败不会被当作“新版已发布”。
The workflow shares the global publication lock. A proven newer Stable pointer
preserves the newer site. Each Pages dispatch has a unique run/attempt correlation
ID, and Pages rechecks ownership immediately before deploying. The final verifier
checks the hydrated download page. A 90-day ledger records the source/control SHA,
Pages run, observed surfaces, and exact failed stage; a failed HTTP observation
never becomes a successful newer-pointer result. Recovery success is not a claim
that unavailable services were verified.
流程共用全局发布锁;只有确证存在更新 Stable 版本才保留新官网。Pages 调度带当前 run/attempt 的
唯一关联 ID,部署前再次检查版本归属,最后验证浏览器渲染后的下载页。90 天台账保留产品/控制 SHA、
Pages run、已验证渠道及失败阶段;HTTP 失败不会再被当作“新版已部署”。外部访问未恢复时不会报告验收完成。
The website is not part of this pipeline. It is built and deployed from the
`website` branch, and its changelog renders from that branch's own release
catalog, so a new version's page is released there, not here.
官网不在此流程内:它由 `website` 分支独立构建和部署,更新日志由该分支自己的发布目录渲染,新版本页面在那里发布。
For qualification without publication, dispatch `Prepare release candidate` on
protected `main-v2` with `version` and `rehearsal=true`. An already reviewed
@@ -56,7 +64,7 @@ version may be used for this isolated run. It uses separate
cannot pass the normal publish resolver or payload verifier. The Desktop child
accepts the existing version tag only in this non-publishing mode. The run must
still complete source CI, signing, and native acceptance. It creates no tags,
GitHub Releases, npm packages, Homebrew updates, R2 pointers, or site changes.
GitHub Releases, npm packages, Homebrew updates, or R2 pointers.
After sealing, run `Verify release candidate rehearsal` on `main-v2` with its
candidate ID. This independent workflow downloads the exact record and payload
@@ -69,7 +77,7 @@ not a publication or a substitute for a later formal release's public checks.
The candidate payload lasts 30 days and its record/evidence 90 days. If the
payload expires before publication, prepare a new candidate. The release
skill's public postflight remains the authority for tags, npm, Desktop
updates, Homebrew, and the hydrated website after an authorized publication.
updates, and Homebrew after an authorized publication.
## Tag publisher identity / 标签发布身份
+13
View File
@@ -6,6 +6,19 @@ Desktop 文件、执行最终安装包的原生验收并封存记录。`Publish
只接受该记录,验证来源与文件字节,经过一次 `release` 批准后创建三个标签并发布。
`recover` 复用相同的封存文件,不重新构建或签名。
渲染发布说明时会为贡献者署名:`refs` 中属于合并请求的编号后面加上
`by @作者`;属于 issue 的编号,对 GitHub 记录为关闭它的每个已合并请求加上
`fixed in #M by @作者`。结尾的「贡献者」列表按首次出现顺序列出这些真人作者,
每人一次。直接推送修复的 issue 和机器人作者保持原样。查询需要 GitHub 令牌(`GH_TOKEN`,
本地没有时用 `gh auth token`),以及 `issues: read` 和 `pull-requests: read` 权限。
暂时性失败(5xx、限流、连接中断)会退避重试;重试后仍失败或鉴权失败时,渲染以
`release_credits.*` 错误码失败,而不是发布缺少署名的说明。指向讨论或不存在的编号保持原样并给出警告。
封存任务只渲染一次,写进候选包的 `evidence/release-notes.md`,记录为
`notes.renderedPath` 并与 `notes.renderedSha256` 绑定。发布时校验候选包后直接发布这份
内容,不再访问 GitHub,因此封存之后作者改名、issue 新关联了修复或接口故障都不会改变或
阻塞发布内容。本改动之前封存的候选没有 `renderedPath`,仍按原方式从自身源码离线渲染。
需要验收但不发布时,在受保护的 `main-v2` 上调度 `Prepare release candidate`,
填写 `version` 和 `rehearsal=true`。可使用已有审核完成的版本进行隔离演练。
它使用独立的 `release-candidate-rehearsal-*` 产物,记录
+4 -1
View File
@@ -1134,7 +1134,10 @@ cursor behavior.
`[serve]` controls the HTTP browser frontend used by `reasonix serve`. The
default `auth_mode = "none"` is intended for the loopback default
`127.0.0.1:8787`; deployments reachable from another machine must use `token` or
`password`. Password mode requires either a startup `--password` or a stored
`password`. In `none` mode every state-changing request still needs the launch
token, which Serve writes to a 0600 file under `<Reasonix home>/remote/` rather
than to the terminal, and otherwise refuses with `launch_token_required`.
`[serve]` is read from the user config only. Password mode requires either a startup `--password` or a stored
bcrypt `password_hash`. `behind_proxy` must stay false unless the server is
behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto`
headers.
+1 -1
View File
@@ -453,7 +453,7 @@ auth_mode = "none"
权限预设直接选择强制沙盒边界。交互会话可以用「扩展写入范围」授权(仅本次 / 本会话 / 拒绝)按需扩大可写根;文件工具会申请目标父目录,Bash 必须声明 `additional_write_dirs` 和 `justification`。无头 `reasonix run` 缺少目录时失败关闭。file writer 默认限制在 workspace root、会话私有临时目录和显式授权根;`forbid_read` 可阻止读取敏感路径。macOS 使用 Seatbelt,Linux 使用 bubblewrap;受限 backend 不可用时拒绝执行,不无约束回退。Windows 没有 OS 级 Shell 沙箱:受限预设仍约束文件工具,Shell 命令以当前系统账户运行。
`[serve]` 控制 `reasonix serve` 的 browser frontend。默认 `auth_mode = "none"` 仅适合 loopback;暴露到其他机器时必须使用 token 或 password。只有位于可信 reverse proxy 后方时才能启用 `behind_proxy`。
`[serve]` 控制 `reasonix serve` 的 browser frontend。默认 `auth_mode = "none"` 仅适合 loopback;暴露到其他机器时必须使用 token 或 password。`none` 模式下所有会改变状态的请求仍需启动令牌(serve 把它写进 `<Reasonix home>/remote/` 下 0600 文件而非终端),否则以 `launch_token_required` 拒绝;`[serve]` 只从用户配置读取。只有位于可信 reverse proxy 后方时才能启用 `behind_proxy`。
项目根目录的 `.mcp.json` 可使用 Claude Code 的 `mcpServers` schema;与 `reasonix.toml` 同名时,以后者为准。
+6 -6
View File
@@ -323,9 +323,9 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
sess := &acpSession{id: "sess-1", ctrl: newExtActionController(t, client)}
svc := &service{}
got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
if got != "rerun scheduled" {
t.Fatalf("resolveSlashPrompt = %q, want the action result", got)
got, invoked := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
if got != "rerun scheduled" || invoked != "" {
t.Fatalf("resolveSlashPrompt = (%q, %q), want the action result without a skill", got, invoked)
}
if client.got == nil || client.got.ActionID != "act1" ||
client.got.Args["k"] != "v" || client.got.Args["arg1"] != "extra" {
@@ -333,10 +333,10 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
}
// Undeclared actions and non-action lines pass through untouched.
if got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
t.Fatalf("undeclared action rewrote to %q", got)
}
if got := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
t.Fatalf("plain slash rewrote to %q", got)
}
@@ -345,7 +345,7 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
failing := &acpSession{id: "sess-1", ctrl: newExtActionController(t, &extActionClient{
result: protocol.UIActionResult{Accepted: false, Message: "nope"},
})}
if got := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
if got, _ := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
t.Fatalf("failed action rewrote to %q", got)
}
}
+15 -11
View File
@@ -263,6 +263,8 @@ func (s *service) bindClientIO(p *SessionParams, sessionID string) {
type acpController interface {
control.Lifecycle
control.TurnControl
RunTurnWithRaw(ctx context.Context, input, raw, invokedSkill string) error
RunSkillWithName(input string) (sent, name string, found bool)
RunFinalReadinessRecoveryWithAdmission(ctx context.Context, input string, onAdmitted func()) error
TrySteer(text string) bool
control.Approvals
@@ -1158,6 +1160,8 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: unknown session " + p.SessionID}
}
text := FlattenPrompt(p.Prompt)
rawText := text
invokedSkill := ""
if text == "" && p.Action != control.ProtocolRecoveryAction {
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: empty prompt"}
}
@@ -1178,7 +1182,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
recovery = true
text = prompt
} else {
text = s.resolveSlashPrompt(ctx, sess, text)
text, invokedSkill = s.resolveSlashPrompt(ctx, sess, text)
}
}
@@ -1217,7 +1221,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
runErr = sess.ctrl.RunFinalReadinessRecoveryWithAdmission(runCtx, text, beginTurn)
} else {
beginTurn()
runErr = sess.ctrl.RunTurn(runCtx, text)
runErr = sess.ctrl.RunTurnWithRaw(runCtx, text, rawText, invokedSkill)
}
if errors.Is(runErr, agent.ErrProtocolRecoveryUnavailable) && !statusStarted {
return nil, &RPCError{Code: ErrInvalidRequest, Message: "session/prompt: protocol recovery is unavailable or stale"}
@@ -2588,28 +2592,28 @@ func availableCommandsFor(ctrl acpController) []AvailableCommand {
return out
}
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) string {
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) (string, string) {
line := strings.TrimSpace(text)
if sess == nil || !strings.HasPrefix(line, "/") {
return text
return text, ""
}
ctrl := sess.currentCtrl()
if ctrl == nil {
return text
return text, ""
}
if sent, ok := ctrl.CustomCommand(line); ok {
return sent
return sent, ""
}
if sent, ok := ctrl.RunSkill(line); ok {
return sent
if sent, name, ok := ctrl.RunSkillWithName(line); ok {
return sent, name
}
if sent, ok, err := ctrl.MCPPrompt(ctx, line); err == nil && ok {
return sent
return sent, ""
}
if sent, ok := invokeExtensionAction(ctx, ctrl, line); ok {
return sent
return sent, ""
}
return text
return text, ""
}
// invokeExtensionAction resolves a "/<plugin>:<action> args…" line against the
+1 -1
View File
@@ -424,7 +424,7 @@ func TestACPCtrlReadPathsDoNotRaceWithRebuild(t *testing.T) {
}
_ = svc.sessionDir()
svc.sendAvailableCommands(sess)
if got := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
t.Fatalf("resolveSlashPrompt rewrote unknown command to %q", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
package acp
import (
"context"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/agent/testutil"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
type skillRawFactory struct {
provider *testutil.MockProvider
executor chan *agent.Agent
dir string
}
func (f *skillRawFactory) SessionDir() string { return f.dir }
func (f *skillRawFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
executor := agent.New(f.provider, tool.NewRegistry(), agent.NewSession("test agent"),
agent.Options{MaxSteps: 2}, p.Sink)
ctrl := control.New(control.Options{
Runner: executor, Executor: executor, Sink: p.Sink,
SessionDir: f.dir,
Skills: []skill.Skill{
{Name: "probe", Description: "probe skill", Body: "PROBE BODY\nthen /other", Triggers: []string{"probe", "tidy"}, AutoUse: "require"},
{Name: "other", Description: "other skill", Body: "OTHER BODY", Triggers: []string{"other"}, AutoUse: "require"},
},
})
f.executor <- executor
return ctrl, nil
}
func TestACPSkillPromptKeepsTypedTextAsRawInput(t *testing.T) {
factory := &skillRawFactory{
provider: testutil.NewMock("test", testutil.Turn{Text: "done"}),
executor: make(chan *agent.Agent, 1),
dir: t.TempDir(),
}
client, stop := startServer(t, factory)
defer stop()
sid := openSession(t, client)
executor := <-factory.executor
const typed = "/probe tidy the notes"
prompt := client.callAsync("session/prompt", SessionPromptParams{
SessionID: sid,
Prompt: []ContentBlock{{Type: "text", Text: typed}},
})
_, resp := drainPrompt(t, client, prompt)
if resp.Error != nil {
t.Fatalf("session/prompt: %v", resp.Error)
}
req := factory.provider.LastRequest()
if req == nil {
t.Fatal("no provider request")
}
var delivered string
for _, msg := range req.Messages {
if msg.Role == provider.RoleUser {
delivered = msg.Content
}
}
if !strings.Contains(delivered, "PROBE BODY") || !strings.Contains(delivered, "Arguments: tidy the notes") {
t.Fatalf("provider did not receive the rendered skill invocation: %q", delivered)
}
if strings.Contains(delivered, "<capability-route") {
t.Fatalf("already-pinned skill was routed again: %q", delivered)
}
for _, msg := range executor.Session().Snapshot() {
if msg.Role == provider.RoleUser && strings.Contains(msg.Content, "PROBE BODY") {
if msg.RawContent != typed {
t.Fatalf("raw user input = %q, want %q", msg.RawContent, typed)
}
return
}
}
t.Fatal("no user turn in history")
}
+31 -2
View File
@@ -4,6 +4,9 @@ import (
"errors"
"fmt"
"strings"
"reasonix/internal/config"
"reasonix/internal/plugin"
)
func (r *MCPCapabilityRuntime) serverRegistered(server string) bool {
@@ -24,12 +27,32 @@ func mcpServerDisabledMessage(server string) string {
return fmt.Sprintf("MCP server %q is disabled in this session", server)
}
// mcpServerOffMessage carries the host's reason a server is off. One the
// project declared waits for the user, and only the user can change that.
func mcpServerOffMessage(server string, decision config.MCPDecision) string {
if !decision.AwaitsUser() {
return mcpServerDisabledMessage(server)
}
return fmt.Sprintf("MCP server %q is off [%s]: this project declares it, and it starts only after the user reviews and enables it (reasonix mcp enable %s, /mcp connect, or the MCP panel). Tell the user if it is needed.", server, decision.Code(), server)
}
// serverDecision resolves why entry is off; an enabled server is simply on.
func serverDecision(entry config.PluginEntry, spec plugin.Spec, enabled bool) config.MCPDecision {
if enabled {
return config.MCPDecisionOn
}
if d := config.MCPServerDecision(entry, spec.WorkspaceRoot); d.AwaitsUser() {
return d
}
return config.MCPDecisionOff
}
func mcpServerUnregisteredError(server string) error {
return errors.New(mcpServerUnregisteredMessage(server))
}
func mcpServerDisabledError(server string) error {
return errors.New(mcpServerDisabledMessage(server))
func mcpServerOffError(server string, decision config.MCPDecision) error {
return errors.New(mcpServerOffMessage(server, decision))
}
func (t *UseCapabilityTool) serverRegistered(server string) bool {
@@ -46,5 +69,11 @@ func (t *UseCapabilityTool) serverUnavailableReason(server string) string {
if !t.serverRegistered(server) {
return mcpServerUnregisteredMessage(server)
}
if t.runtime != nil {
t.runtime.mu.RLock()
configured := t.runtime.servers[strings.TrimSpace(server)]
t.runtime.mu.RUnlock()
return mcpServerOffMessage(server, configured.decision)
}
return mcpServerDisabledMessage(server)
}
+15 -3
View File
@@ -46,6 +46,7 @@ type mcpRuntimeServer struct {
entry config.PluginEntry
spec plugin.Spec
enabled bool
decision config.MCPDecision // why a disabled server is off
cached []plugin.CachedTool
cacheKeyOK bool
}
@@ -107,7 +108,7 @@ func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, sp
for _, entry := range entries {
name := strings.TrimSpace(entry.Name)
if name != "" {
byName[name] = runtimePluginEntry(entry)
byName[name] = entry
}
}
next := make(map[string]mcpRuntimeServer, len(specs))
@@ -125,11 +126,16 @@ func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, sp
if enabled != nil {
isEnabled = enabled[name]
}
decision := config.MCPDecisionOn
if ok {
decision = serverDecision(entry, spec, isEnabled)
}
cached, keyOK := cachedToolsForSpec(spec, r.hostProfile())
next[name] = mcpRuntimeServer{
entry: entry,
entry: runtimePluginEntry(entry),
spec: spec,
enabled: isEnabled,
decision: decision,
cached: cached,
cacheKeyOK: keyOK,
}
@@ -155,6 +161,7 @@ func (r *MCPCapabilityRuntime) UpsertServer(entry config.PluginEntry, raw plugin
if name == "" {
return
}
decision := serverDecision(entry, spec, enabled)
entry = runtimePluginEntry(entry)
if strings.TrimSpace(entry.Name) == "" {
entry.Name = name
@@ -165,6 +172,7 @@ func (r *MCPCapabilityRuntime) UpsertServer(entry config.PluginEntry, raw plugin
entry: entry,
spec: spec,
enabled: enabled,
decision: decision,
cached: cached,
cacheKeyOK: keyOK,
}
@@ -189,6 +197,10 @@ func (r *MCPCapabilityRuntime) SetServerEnabled(name string, enabled bool) bool
server, ok := r.servers[name]
if ok {
server.enabled = enabled
server.decision = config.MCPDecisionOff
if enabled {
server.decision = config.MCPDecisionOn
}
r.servers[name] = server
}
r.mu.Unlock()
@@ -1201,7 +1213,7 @@ func (t *UseCapabilityTool) lockAuthorizedRuntimeServer(ctx context.Context, ser
}
if !configured.enabled {
unlock()
return plugin.Spec{}, func() {}, mcpServerDisabledError(server)
return plugin.Spec{}, func() {}, mcpServerOffError(server, configured.decision)
}
spec := plugin.ResolveStoredAuthorization(ctx, cloneMCPSpec(configured.spec))
if !spec.ServerAuthorized() {
+1 -1
View File
@@ -136,7 +136,7 @@ func (t *UseCapabilityTool) listServers() (string, error) {
connected := server.enabled && resolved.ServerAuthorized() && t.host != nil && t.host.HasClientForSpec(resolved)
status := "configured"
if !server.enabled {
status = "disabled"
status = server.decision.Code()
} else if connected {
status = "ready"
} else if t.host != nil {
+2 -2
View File
@@ -21,8 +21,8 @@ func TestUseCapabilityListSummarizesMCPWithoutExpandingCachedDirectories(t *test
{Name: "enabled", Type: "stdio", Command: "enabled-mcp", Authorized: true},
}
entries := []config.PluginEntry{
{Name: "disabled", Type: "stdio", Command: "disabled-mcp"},
{Name: "enabled", Type: "stdio", Command: "enabled-mcp"},
{Name: "disabled", Type: "stdio", Command: "disabled-mcp", Source: config.MCPSourceUserConfig},
{Name: "enabled", Type: "stdio", Command: "enabled-mcp", Source: config.MCPSourceUserConfig},
}
for _, spec := range specs {
cached := make([]plugin.CachedTool, 64)
+2 -2
View File
@@ -23,9 +23,9 @@ func TestAdditionalDirsResolveAgainstExplicitWorkspaceRoot(t *testing.T) {
}
// An explicit workspace root (from --dir) pins resolution to proj, not the git root.
root := resolveWorkspaceRoot(proj)
root := ResolveWorkspaceRoot(proj)
if root != proj {
t.Fatalf("resolveWorkspaceRoot(%q) = %q, want the explicit --dir", proj, root)
t.Fatalf("ResolveWorkspaceRoot(%q) = %q, want the explicit --dir", proj, root)
}
// A relative --add-dir resolves against that explicit root (proj/shared), not
+13 -3
View File
@@ -40,6 +40,7 @@ import (
"reasonix/internal/extension/providerext"
"reasonix/internal/extension/sidecar"
"reasonix/internal/extension/uihub"
"reasonix/internal/gitcmd"
"reasonix/internal/guardian"
"reasonix/internal/history"
"reasonix/internal/hook"
@@ -234,6 +235,7 @@ type Options struct {
WorkspaceOnly bool
PinnedContextLoader control.PinnedContextLoader
SessionTemp *sessiontemp.Manager // session-private temp manager; Rebuild reuses old's
WorkspaceRepo gitcmd.Repo // git identity the session opened with; Rebuild reuses old's
PersistentShell *persistentshell.Manager
RuntimeReload
// deferPublish keeps a replacement generation private until migration and
@@ -256,7 +258,8 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
if stderr == nil {
stderr = os.Stderr
}
root := resolveWorkspaceRoot(opts.WorkspaceRoot)
root := ResolveWorkspaceRoot(opts.WorkspaceRoot)
repo := workspaceRepo(ctx, opts.WorkspaceRepo, root)
additionalDirs, err := normalizeAdditionalDirs(root, opts.AdditionalDirs)
if err != nil {
return nil, err
@@ -787,6 +790,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
OAuthHTTPClient: balanceClient,
}
autoStartEntries := cfg.EnabledPlugins(root, config.DefaultMCPActivationStore())
emitProjectMCPDecisionNotice(sink, cfg, root)
enabledMCPNames := make(map[string]bool, len(autoStartEntries))
for _, enabled := range autoStartEntries {
if name := strings.TrimSpace(enabled.Name); name != "" {
@@ -1478,7 +1482,9 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
slashEntries = append(slashEntries, command.SlashEntry{
Name: sk.SlashName(),
Description: sk.Description,
Render: func(args []string) string { return skillStore.Render(sk, strings.Join(args, " ")) },
Render: func(args []string) string {
return skill.RenderInvocation(skillStore.Prepare(sk), strings.Join(args, " "))
},
})
}
}
@@ -1884,6 +1890,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
},
CapabilityRuntime: capRuntime,
WorkspaceRoot: root,
WorkspaceRepo: repo,
ExternalFolderToolRefs: readPathResolver,
ResponseLanguage: cfg.ResponseLanguage(),
ReasoningLanguage: config.ReasoningLanguageForEntry(entry, cfg.ReasoningLanguage()),
@@ -2287,7 +2294,9 @@ func currentWorkspacePromptLine(root string) string {
return "Current workspace: " + strconv.Quote(root)
}
func resolveWorkspaceRoot(explicit string) string {
// ResolveWorkspaceRoot is the workspace a session in the current directory uses:
// explicit, else the nearest git root, else the working directory.
func ResolveWorkspaceRoot(explicit string) string {
if explicit != "" {
return explicit
}
@@ -2393,6 +2402,7 @@ func runtimeForbidReadRootsForGOOS(cfg *config.Config, root, goos string) []stri
if goos == "windows" {
return append([]string(nil), base...)
}
base = appendUniquePaths(base, config.HostSecretReadRoots()...)
credentialPath := strings.TrimSpace(config.UserCredentialsPath())
if credentialPath == "" {
return append([]string(nil), base...)
+4 -1
View File
@@ -3936,6 +3936,7 @@ command = "reasonix-missing-legacy-eager-mcp"
tier = "eager"
`)
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{})
@@ -3981,6 +3982,7 @@ command = "reasonix-missing-legacy-lazy-mcp"
tier = "lazy"
`)
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{})
@@ -4082,7 +4084,7 @@ func TestRuntimeForbidReadRootsAddsGlobalCredentialFileExceptOnWindows(t *testin
cfg := config.Default()
cfg.Sandbox.ForbidRead = []string{configured}
withoutCredentials := RuntimeForbidReadRoots(cfg, ".")
if !reflect.DeepEqual(withoutCredentials, []string{configured}) {
if want := appendUniquePaths([]string{configured}, config.HostSecretReadRoots()...); runtime.GOOS != "windows" && !reflect.DeepEqual(withoutCredentials, want) {
t.Fatalf("roots without global credentials = %v", withoutCredentials)
}
credentialPath := config.UserCredentialsPath()
@@ -4302,6 +4304,7 @@ tier = "eager"
`)
var notices []event.Event
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ctrl, err := Build(ctx, Options{
@@ -0,0 +1,99 @@
package boot
import (
"context"
"strings"
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// A user-typed inline skill invocation must reach the provider in the same
// pinned form run_skill returns, on both the string and the structured submit path.
func TestEffectInlineSkillInvocationReachesProviderPinned(t *testing.T) {
const pinOpen = "<skill-pin name=\"probe\">\n# Skill: probe"
cases := []struct {
name string
submit func(*control.Controller)
tail string
raw string
}{
{"slash bare", func(c *control.Controller) { c.Submit("/probe") }, "PROBE BODY\nthen run /other\n</skill-pin>", "/probe"},
{"slash with task", func(c *control.Controller) { c.Submit("/probe tidy the notes") }, "PROBE BODY\nthen run /other\n\nArguments: tidy the notes\n</skill-pin>", "/probe tidy the notes"},
{"chip bare", func(c *control.Controller) {
c.SubmitInvocationDisplay("/probe", "", []control.InvocationRequest{{Name: "probe", Kind: "skill"}})
}, "PROBE BODY\nthen run /other\n</skill-pin>", ""},
{"chip with task", func(c *control.Controller) {
c.SubmitInvocationDisplay("/probe tidy the notes", "tidy the notes", []control.InvocationRequest{{Name: "probe", Kind: "skill"}})
}, "PROBE BODY\nthen run /other\n\nArguments: tidy the notes\n</skill-pin>", "tidy the notes"},
}
var rec *effectRecordingProvider
provider.Register("boot-effect-skill-invocation", func(provider.Config) (provider.Provider, error) { return rec, nil })
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
rec = &effectRecordingProvider{}
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[environment]
enabled = false
[[providers]]
name = "test-model"
kind = "boot-effect-skill-invocation"
model = "x"
`)
writeFile(t, dir, ".reasonix/skills/probe/SKILL.md", "---\ndescription: probe skill\ntriggers: probe, tidy\nauto-use: require\n---\nPROBE BODY\nthen run /other")
writeFile(t, dir, ".reasonix/skills/other/SKILL.md", "---\ndescription: other skill\ntriggers: other\nauto-use: require\n---\nOTHER BODY")
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
tc.submit(ctrl)
if done, running := ctrl.TurnIdleDone(); running {
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("turn did not finish")
}
}
reqs := rec.requests()
if len(reqs) == 0 {
t.Fatal("no request reached the provider boundary")
}
req := reqs[len(reqs)-1]
if raw := rec.rawUserInputs(); len(raw) == 0 || raw[len(raw)-1] != tc.raw {
t.Fatalf("provider raw user input = %q, want %q", raw, tc.raw)
}
var user string
for _, m := range req.Messages {
if m.Role == provider.RoleUser {
user = m.Content
}
}
if !strings.Contains(user, pinOpen) || !strings.Contains(user, tc.tail) {
t.Fatalf("user message is not the pinned invocation (want tail %q):\n%s", tc.tail, user)
}
if strings.Count(user, "tidy the notes") > 1 {
t.Fatalf("task delivered twice:\n%s", user)
}
if strings.Contains(systemMessage(req.Messages), "PROBE BODY") {
t.Fatal("invocation body leaked into the cache-stable prefix")
}
if strings.Contains(user, "skill:probe require") || strings.Contains(user, "skill:other require") {
t.Fatalf("inline skill already supplied to the model must not require another skill call:\n%s", user)
}
})
}
}
+11 -3
View File
@@ -20,15 +20,17 @@ import (
)
type effectRecordingProvider struct {
mu sync.Mutex
reqs []provider.Request
mu sync.Mutex
reqs []provider.Request
rawInputs []string
}
func (p *effectRecordingProvider) Name() string { return "boot-effect-test" }
func (p *effectRecordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
func (p *effectRecordingProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, req)
p.rawInputs = append(p.rawInputs, agent.RawUserInput(ctx, ""))
p.mu.Unlock()
chunks := []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
@@ -42,6 +44,12 @@ func (p *effectRecordingProvider) Stream(_ context.Context, req provider.Request
return ch, nil
}
func (p *effectRecordingProvider) rawUserInputs() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.rawInputs...)
}
func (p *effectRecordingProvider) requests() []provider.Request {
p.mu.Lock()
defer p.mu.Unlock()
+72
View File
@@ -0,0 +1,72 @@
package boot
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/sandbox"
)
func TestRuntimeForbidReadRootsCoverServeLaunchTokens(t *testing.T) {
t.Setenv("REASONIX_HOME", filepath.Join(isolateConfigHome(t), "reasonix-home"))
tokenFile := filepath.Join(t.TempDir(), "serve.token")
config.RegisterHostSecretPath(tokenFile)
got := runtimeForbidReadRootsForGOOS(config.Default(), ".", "darwin")
for _, want := range []string{config.RemoteStateDir(), tokenFile} {
if !pathListContains(got, want) {
t.Fatalf("runtime forbid roots = %v, missing %s", got, want)
}
}
if windows := runtimeForbidReadRootsForGOOS(config.Default(), ".", "windows"); pathListContains(windows, tokenFile) {
t.Fatalf("Windows runtime forbid roots = %v; the host ACL model cannot deny them", windows)
}
}
func TestSandboxedShellCannotReadServeLaunchTokens(t *testing.T) {
if runtime.GOOS == "windows" || !sandbox.Available() {
t.Skip("OS sandbox unavailable")
}
isolateConfigHome(t)
// Every file sits inside the write root: Linux masks /tmp with a tmpfs, so a
// file anywhere else under it would read as missing whether denied or not.
work := robustTempDir(t)
t.Setenv("REASONIX_HOME", filepath.Join(work, "reasonix-home"))
remoteDir := config.RemoteStateDir()
if err := os.MkdirAll(remoteDir, 0o700); err != nil {
t.Fatal(err)
}
const secret = "launch-token-sentinel"
remoteToken := filepath.Join(remoteDir, "serve-demo.token")
flagToken := filepath.Join(work, "serve.token")
visible := filepath.Join(work, "visible.txt")
for _, path := range []string{remoteToken, flagToken, visible} {
if err := os.WriteFile(path, []byte(secret), 0o600); err != nil {
t.Fatal(err)
}
}
config.RegisterHostSecretPath(flagToken)
spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{work}, ForbidReadRoots: RuntimeForbidReadRoots(config.Default(), work), Network: true}
read := func(path string) (string, error) {
argv, wrapped := sandbox.Command(spec, sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}, "cat "+path)
if !wrapped {
t.Fatal("sandbox did not wrap the command")
}
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
return string(out), err
}
if out, err := read(visible); err != nil || !strings.Contains(out, secret) {
t.Fatalf("control read = %q, %v; the sandbox must still read ordinary files", out, err)
}
for _, path := range []string{remoteToken, flagToken} {
if out, err := read(path); err == nil || strings.Contains(out, secret) {
t.Fatalf("sandboxed read of %s = %q, %v; want denied", path, out, err)
}
}
}
@@ -0,0 +1,273 @@
package boot
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/agent/testutil"
"reasonix/internal/config"
"reasonix/internal/event"
)
func countingMCPStub(t *testing.T, name string) (*httptest.Server, *atomic.Int32) {
t.Helper()
inner := mcpHostSessionStub(t, name, nil)
handler := inner.Config.Handler
inner.Close()
hits := &atomic.Int32{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
handler.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
return srv, hits
}
// 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()
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, root, true); err != nil {
t.Fatal(err)
}
}
}
}
func waitForHit(hits *atomic.Int32, within time.Duration) bool {
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if hits.Load() > 0 {
return true
}
time.Sleep(20 * time.Millisecond)
}
return hits.Load() > 0
}
// A project's configuration cannot choose MCP servers the host starts: a
// repository-declared server stays idle until the user enables it, while a
// user-level server keeps starting as before.
func TestEffectProjectDeclaredMCPDoesNotStartThroughRealBuild(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
userSrv, userHits := countingMCPStub(t, "user")
tomlSrv, tomlHits := countingMCPStub(t, "repo-toml")
jsonSrv, jsonHits := countingMCPStub(t, "repo-json")
userConfig := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(userConfig), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(userConfig, []byte("[[plugins]]\nname = \"user-http\"\ntype = \"http\"\nurl = \""+userSrv.URL+"\"\n"), 0o600); err != nil {
t.Fatal(err)
}
marker := filepath.Join(dir, "stdio-started")
stdioPlugin := ""
if runtime.GOOS != "windows" {
stdioPlugin = "\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo started > " + marker + "\"]\n"
}
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
[[plugins]]
name = "repo-toml-http"
type = "http"
url = "`+tomlSrv.URL+`"
auto_start = true
`+stdioPlugin)
writeFile(t, dir, ".mcp.json", `{"mcpServers":{"repo-json-http":{"type":"http","url":"`+jsonSrv.URL+`"}}}`)
build := func() {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
ctrl, err := Build(ctx, Options{})
if err != nil {
t.Fatalf("Build: %v", err)
}
t.Cleanup(ctrl.Close)
}
build()
if !waitForHit(userHits, 5*time.Second) {
t.Fatal("user-level MCP server was never contacted; the probe cannot tell idle from broken")
}
time.Sleep(500 * time.Millisecond)
if n := tomlHits.Load(); n != 0 {
t.Fatalf("project reasonix.toml MCP server received %d requests with no user decision", n)
}
if n := jsonHits.Load(); n != 0 {
t.Fatalf("project .mcp.json MCP server received %d requests with no user decision", n)
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("project stdio MCP command ran with no user decision")
}
cfg, err := config.LoadForRootReadOnly(dir)
if err != nil {
t.Fatal(err)
}
for _, p := range cfg.Plugins {
if p.Name == "repo-toml-http" {
if err := config.DefaultMCPActivationStore().SetServerEnabled(p, dir, true); err != nil {
t.Fatal(err)
}
}
}
build()
if !waitForHit(tomlHits, 5*time.Second) {
t.Fatal("project MCP server stayed idle after the user enabled it")
}
if n := jsonHits.Load(); n != 0 {
t.Fatalf("enabling one project server started another: .mcp.json server got %d requests", n)
}
}
// An enable decision holds for the declaration the user approved; rewriting
// the command afterwards leaves the server off until they approve again.
func TestEffectRewrittenProjectMCPCommandDoesNotRunThroughRealBuild(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("marker command is POSIX shell")
}
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
base := `
default_model = "test-model"
[[providers]]
name = "test-model"
kind = "openai"
base_url = "https://example.invalid"
model = "x"
api_key_env = "REASONIX_TEST_KEY_UNSET"
`
approved := filepath.Join(dir, "approved-ran")
rewritten := filepath.Join(dir, "rewritten-ran")
writeFile(t, dir, "reasonix.toml", base+"\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo ok > "+approved+"\"]\n")
enableProjectMCPForTest(t, dir)
writeFile(t, dir, "reasonix.toml", base+"\n[[plugins]]\nname = \"repo-stdio\"\ncommand = \"sh\"\nargs = [\"-c\", \"echo changed > "+rewritten+"\"]\n")
var notices []event.Event
var mu sync.Mutex
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
ctrl, err := Build(ctx, Options{Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
mu.Lock()
notices = append(notices, e)
mu.Unlock()
}
})})
if err != nil {
t.Fatalf("Build: %v", err)
}
t.Cleanup(ctrl.Close)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(rewritten); err == nil {
t.Fatal("rewritten command of an enabled project server ran with no new decision")
}
time.Sleep(50 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
for _, n := range notices {
if strings.Contains(n.Detail, "repo-stdio [changed_since_enabled]") && strings.Contains(n.Detail, "echo changed > "+rewritten) {
return
}
}
t.Fatalf("no notice named the changed server and its new command; notices=%+v", notices)
}
// The model learns why a project server is off, as a typed cause it can report,
// rather than a generic disabled state it might try to route around.
func TestEffectModelSeesProjectMCPAwaitingUserDecision(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
srv, hits := countingMCPStub(t, "repo")
writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
[[plugins]]
name = "repo-http"
type = "http"
url = "`+srv.URL+`"
`)
out := runUseCapabilityCalls(t, Options{Sink: event.Discard}, "mcp-server:repo-http", "mcp-tool:repo-http/ping")
if !strings.Contains(out, "[awaiting_user_decision]") {
t.Fatalf("model-visible refusal lacks the typed cause:\n%s", out)
}
if n := hits.Load(); n != 0 {
t.Fatalf("pending project server received %d requests during the turn", n)
}
}
// Connecting a pending project server by hand is the user's approval, so the
// next session starts it without asking again.
func TestConnectingPendingProjectMCPRecordsTheDecision(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
srv, hits := countingMCPStub(t, "repo")
writeFile(t, dir, "reasonix.toml", mcpCapabilityTestProviderConfig+`
[[plugins]]
name = "repo-http"
type = "http"
url = "`+srv.URL+`"
`)
registerBootTokenProfileTestProvider()
setBootTokenProfileTestProvider(t, testutil.NewMock("connect", testutil.Turn{Text: "done"}))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
t.Cleanup(cancel)
ctrl, err := Build(ctx, Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
t.Cleanup(ctrl.Close)
if _, err := ctrl.ConnectConfiguredMCPServer("repo-http"); err != nil {
t.Fatalf("connect: %v", err)
}
if hits.Load() == 0 {
t.Fatal("explicit connect never reached the server")
}
cfg, err := config.LoadForRootReadOnly(dir)
if err != nil {
t.Fatal(err)
}
for _, p := range cfg.Plugins {
if p.Name == "repo-http" {
if d := config.MCPServerDecision(p, ctrl.WorkspaceRoot()); d != config.MCPDecisionOn {
t.Fatalf("decision after explicit connect = %s, want enabled", d.Code())
}
return
}
}
t.Fatal("repo-http not loaded")
}
+43
View File
@@ -0,0 +1,43 @@
package boot
import (
"fmt"
"strings"
"sync"
"reasonix/internal/config"
"reasonix/internal/event"
)
// projectMCPNoticesShown keeps the notice to once per process for each
// declaration, so rebuilding a controller does not repeat it.
var projectMCPNoticesShown sync.Map
// emitProjectMCPDecisionNotice tells the user which project-declared servers are
// off pending their decision, with what each would run.
func emitProjectMCPDecisionNotice(sink event.Sink, cfg *config.Config, root string) {
if sink == nil || cfg == nil {
return
}
var lines []string
for _, p := range cfg.Plugins {
d := config.MCPServerDecision(p, root)
if !d.AwaitsUser() {
continue
}
key := strings.Join([]string{config.ReasonixHomeDir(), root, p.Name, d.Code(), config.MCPLaunchLine(p)}, "\x00")
if _, seen := projectMCPNoticesShown.LoadOrStore(key, true); seen {
continue
}
lines = append(lines, fmt.Sprintf(" %s [%s]: %s", p.Name, d.Code(), config.MCPLaunchLine(p)))
}
if len(lines) == 0 {
return
}
sink.Emit(event.Event{
Kind: event.Notice, Level: event.LevelWarn,
Text: "This project declares MCP servers that stay off until you enable them.",
Detail: strings.Join(lines, "\n") +
"\nReview each one (full declaration: `reasonix mcp get <name>`), then enable it with `reasonix mcp enable <name>`, /mcp connect, or the MCP panel.",
})
}
+3
View File
@@ -113,6 +113,9 @@ func rebuildWithPrevious(ctx context.Context, old *control.Controller, previous
if opts.PersistentShell == nil {
opts.PersistentShell = old.PersistentShell()
}
if opts.WorkspaceRepo.Dir == "" {
opts.WorkspaceRepo = old.WorkspaceRepo()
}
home := config.ReasonixHomeDir()
// fromGraph must be the PREVIOUS generation's graph when available.
+53
View File
@@ -0,0 +1,53 @@
package boot
import (
"context"
"os"
"path/filepath"
"time"
"reasonix/internal/gitcmd"
)
// workspaceRepoTimeout bounds the one git call a session open makes.
const workspaceRepoTimeout = 10 * time.Second
// workspaceRepo is root's git identity for the session being built, settled
// before the session runs any command. A rebuild of the same workspace keeps
// the identity its session opened with instead of rediscovering it from files
// that session could since have written.
func workspaceRepo(ctx context.Context, carried gitcmd.Repo, root string) gitcmd.Repo {
abs, err := filepath.Abs(root)
if err != nil {
return gitcmd.Repo{Dir: root}
}
if carried.Dir != "" && filepath.Clean(carried.Dir) == abs {
return carried
}
if !underGitMarker(abs) {
return gitcmd.Repo{Dir: abs}
}
ctx, cancel := context.WithTimeout(ctx, workspaceRepoTimeout)
defer cancel()
repo, err := gitcmd.Open(ctx, abs)
if err != nil {
return gitcmd.Repo{Dir: abs}
}
return repo
}
// underGitMarker reports whether dir or an ancestor holds a .git entry, so a
// workspace outside any repository never starts git (on macOS a missing git is
// an installer prompt, not an error).
func underGitMarker(dir string) bool {
for {
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
return true
}
parent := filepath.Dir(dir)
if parent == dir {
return false
}
dir = parent
}
}
+35
View File
@@ -0,0 +1,35 @@
package boot
import (
"context"
"os/exec"
"path/filepath"
"testing"
"reasonix/internal/gitcmd"
)
// A session resolves its workspace's repository when it opens; a rebuild of
// the same workspace keeps that identity, and another workspace resolves its own.
func TestWorkspaceRepoResolvedAtOpenAndCarriedAcrossRebuilds(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
root := t.TempDir()
if out, err := exec.Command("git", "-C", root, "init", "-q").CombinedOutput(); err != nil {
t.Fatalf("git init: %v: %s", err, out)
}
ctx := context.Background()
repo := workspaceRepo(ctx, gitcmd.Repo{}, root)
if !repo.Valid() {
t.Fatalf("workspaceRepo(%s) = %+v, want a resolved repository", root, repo)
}
carried := gitcmd.Repo{Dir: repo.Dir, GitDir: "/pinned", CommonDir: "/pinned", WorkTree: "/pinned"}
if got := workspaceRepo(ctx, carried, root); got != carried {
t.Fatalf("rebuild of the same workspace = %+v, want the identity it opened with", got)
}
other := t.TempDir()
if got := workspaceRepo(ctx, carried, other); got.Valid() || got.Dir != filepath.Clean(other) {
t.Fatalf("another workspace = %+v, want it resolved on its own (no repository)", got)
}
}
+5 -5
View File
@@ -20,22 +20,22 @@ func TestResolveWorkspaceRootExplicitAndGitFallback(t *testing.T) {
}
// Explicit --dir pins the workspace root, not the git root of the repo.
if got := resolveWorkspaceRoot(sub); got != sub {
t.Fatalf("resolveWorkspaceRoot(%q) = %q, want the explicit dir", sub, got)
if got := ResolveWorkspaceRoot(sub); got != sub {
t.Fatalf("ResolveWorkspaceRoot(%q) = %q, want the explicit dir", sub, got)
}
// No explicit root: fall back to the nearest git root from the CWD.
t.Chdir(sub)
got := resolveWorkspaceRoot("")
got := ResolveWorkspaceRoot("")
wantRoot, err := filepath.EvalSymlinks(repo)
if err != nil {
t.Fatal(err)
}
gotResolved, err := filepath.EvalSymlinks(got)
if err != nil {
t.Fatalf("resolveWorkspaceRoot(%q) returned unusable path %q: %v", "", got, err)
t.Fatalf("ResolveWorkspaceRoot(%q) returned unusable path %q: %v", "", got, err)
}
if gotResolved != wantRoot {
t.Fatalf("resolveWorkspaceRoot(\"\") = %q, want git root %q", got, repo)
t.Fatalf("ResolveWorkspaceRoot(\"\") = %q, want git root %q", got, repo)
}
}
-14
View File
@@ -293,9 +293,6 @@ func RenderTransientBlock(d RouteDecision) string {
func routeEntry(text string, e Entry) (AutoUse, string, bool) {
if e.Kind == KindSkill {
if explicitSkill(text, e.Name) {
return AutoUseRequire, "the user explicitly referenced this skill", true
}
if e.AutoUse == AutoUseOff {
return "", "", false
}
@@ -312,17 +309,6 @@ func routeEntry(text string, e Entry) (AutoUse, string, bool) {
return "", "", false
}
func explicitSkill(text, name string) bool {
n := normalize(name)
return strings.Contains(text, "/"+n) ||
strings.Contains(text, "use "+n+" skill") ||
strings.Contains(text, "using "+n+" skill") ||
strings.Contains(text, "使用 "+n+" skill") ||
strings.Contains(text, "用 "+n+" skill") ||
strings.Contains(text, "使用"+n+"技能") ||
strings.Contains(text, "用"+n+"技能")
}
// namesMCPTool matches the identifier the host mints for the tool. A server
// name or a topic word selects every tool the server exposes, so it is left to
// the semantic router, which reads the whole request.
+6 -7
View File
@@ -26,19 +26,18 @@ func TestRoutePrefersReviewSkillForReviewRequest(t *testing.T) {
}
}
func TestRouteRequiresExplicitSkill(t *testing.T) {
func TestRouteDoesNotTreatTextMentionAsInvocation(t *testing.T) {
entries := SkillEntries([]skill.Skill{{
Name: "audit",
Description: "audit something",
Scope: skill.ScopeProject,
}}, []tool.ContractEntry{{Name: "run_skill"}})
decision := Route("请使用 audit skill 检查一下", entries)
if len(decision.Candidates) == 0 {
t.Fatal("Route returned no candidates")
}
if got := decision.Candidates[0].Policy; got != AutoUseRequire {
t.Fatalf("policy = %s, want require", got)
for _, text := range []string{"/audit 检查一下", "then run /audit", "请使用 audit skill 检查一下"} {
decision := Route(text, entries)
if len(decision.Candidates) != 0 {
t.Errorf("%q must not create a structural skill invocation: %+v", text, decision.Candidates)
}
}
}
+2 -4
View File
@@ -461,6 +461,7 @@ func collectMCP(cfg *config.Config, root, home, reasonixHome string, disp func(s
if cfg == nil {
return rep, issues
}
enabled := enabledMCPNames(cfg, root, reasonixHome)
// Stable order by name.
entries := append([]config.PluginEntry(nil), cfg.Plugins...)
sort.SliceStable(entries, func(i, j int) bool {
@@ -471,13 +472,10 @@ func collectMCP(cfg *config.Config, root, home, reasonixHome string, disp func(s
Name: p.Name,
Effective: true,
Transport: transportOf(p.Type),
StartIntent: "automatic",
StartIntent: startIntent(enabled[p.Name]),
EnvKeys: sortedKeys(p.Env),
HeaderKeys: sortedKeys(p.Headers),
}
if !p.ShouldAutoStart() {
info.StartIntent = "off"
}
if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
info.PackageOwner = owner
info.Source = "plugin_package"
+25 -2
View File
@@ -35,10 +35,12 @@ func probeLiveMCP(rep *MCPReport, cfg *config.Config, root, home, reasonixHome s
timeout = MaxLiveTimeout
}
// Only probe automatic start intent servers.
// Only probe servers that would start on their own; a project-declared one
// the user has not enabled must not start here either.
enabled := enabledMCPNames(cfg, root, reasonixHome)
var auto []config.PluginEntry
for _, p := range cfg.Plugins {
if p.ShouldAutoStart() {
if enabled[p.Name] {
auto = append(auto, p)
} else {
for i := range rep.Servers {
@@ -155,3 +157,24 @@ They may access the network and receive configured environment variables and hea
Tools are not registered into the agent registry. Startup stats/schema cache writes are disabled.
Host is always closed after the probe.`)
}
// enabledMCPNames resolves which servers start on their own, reading the
// activation store under reasonixHome.
func enabledMCPNames(cfg *config.Config, root, reasonixHome string) map[string]bool {
store := config.DefaultMCPActivationStore()
if strings.TrimSpace(reasonixHome) != "" {
store = config.NewMCPActivationStore(reasonixHome)
}
out := map[string]bool{}
for _, p := range cfg.EnabledPlugins(root, store) {
out[p.Name] = true
}
return out
}
func startIntent(enabled bool) string {
if enabled {
return "automatic"
}
return "off"
}
+19 -2
View File
@@ -29,6 +29,7 @@ import (
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/gitcmd"
"reasonix/internal/hook"
"reasonix/internal/i18n"
"reasonix/internal/memory"
@@ -572,9 +573,16 @@ func runStatuslineCmd(cmd, stdinPayload string) string {
}
func runStatuslineCmdWithTimeout(cmd, stdinPayload string, timeout time.Duration) string {
res := hook.DefaultSpawner(context.Background(), hook.SpawnInput{
return runStatuslineCmdWith(hook.DefaultSpawner, cmd, stdinPayload, timeout)
}
// runStatuslineCmdWith spawns from the workspace, which the user does not
// author, so a bare command name resolves through PATH alone.
func runStatuslineCmdWith(spawn hook.Spawner, cmd, stdinPayload string, timeout time.Duration) string {
res := spawn(context.Background(), hook.SpawnInput{
Command: cmd,
Stdin: stdinPayload + "\n",
Env: map[string]string{hook.NoCwdCommandSearchEnv: "1"},
Timeout: timeout,
})
out := strings.TrimSpace(res.Stdout)
@@ -588,7 +596,16 @@ func (m chatTUI) refreshGitStatus() tea.Cmd {
if m.statuslineCmd != "" {
return nil
}
return fetchGitStatus()
return fetchGitStatus(sessionWorkspaceRepo(m.ctrl))
}
// sessionWorkspaceRepo is the git identity ctrl's session opened with; a
// controller that carries none has no status line to read.
func sessionWorkspaceRepo(ctrl control.SessionAPI) gitcmd.Repo {
if c, ok := ctrl.(interface{ WorkspaceRepo() gitcmd.Repo }); ok {
return c.WorkspaceRepo()
}
return gitcmd.Repo{}
}
// modelSwitchMsg carries the result of an async /model switch. A nil err means
+31 -20
View File
@@ -792,7 +792,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
if opts.command == "web" {
sessionID = fs.String("session-id", "", "bind a fresh Web session identity (used by /web handoff)")
}
authHelp := "auth mode: none, token, or password (default: config/none)"
authHelp := "auth mode: none, token, or password (default: config/none; none still requires the launch token for changes)"
if opts.command == "web" {
authHelp = "auth mode: none, token, or password (default: generated token)"
}
@@ -867,6 +867,7 @@ func runServeWithOptions(args []string, opts serveRunOptions) int {
return 1
}
serveCfg.Token = tok
config.RegisterHostSecretPath(*tokenFile)
}
if *behindProxy {
serveCfg.BehindProxy = true
@@ -1851,11 +1852,16 @@ func repairInvalidProviderKeyEnvs(providers []config.ProviderEntry) ([]config.Pr
return providers, repairs
}
func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string {
// promptAPIKeyEnvName reports explicit=false when the user pressed Enter: def
// is then only a draft name, and a saved key goes to a private slot instead.
func promptAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) (keyEnv string, explicit bool) {
for {
keyEnv := ask(in, w, label, def)
keyEnv = ask(in, w, label, "")
if keyEnv == "" {
return def, false
}
if config.IsValidCredentialKey(keyEnv) {
return keyEnv
return keyEnv, true
}
fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", keyEnv)
}
@@ -1891,10 +1897,13 @@ func familyOf(name string) providerFamily {
type providerPromptResult struct {
entries []config.ProviderEntry
credentials map[string]string
// keyEnvTyped: the user typed the entries' api_key_env rather than accepting keyEnvDraft.
keyEnvTyped bool
keyEnvDraft string
}
func newProviderPromptResult(entries []config.ProviderEntry, key, value string) providerPromptResult {
result := providerPromptResult{entries: entries}
func newProviderPromptResult(entries []config.ProviderEntry, key, value string, typed bool, draft string) providerPromptResult {
result := providerPromptResult{entries: entries, keyEnvTyped: typed, keyEnvDraft: draft}
if key != "" && value != "" {
result.credentials = map[string]string{key: value}
}
@@ -1918,7 +1927,7 @@ func promptCustomProvider(proxy netclient.ProxySpec) (providerPromptResult, erro
// promptCustomProviderManual handles manual model entry.
func promptCustomProviderManual() (providerPromptResult, error) {
return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
return promptCustomProviderManualWith(bufio.NewScanner(os.Stdin), "", "", false, "")
}
// promptCustomProviderManualWith is the shared backend for manual entry.
@@ -1926,7 +1935,7 @@ func promptCustomProviderManual() (providerPromptResult, error) {
// so the URL-fetch flow can fall through to manual entry without re-asking
// the user for information they've already typed. An empty apiKey is allowed
// — the key step happens later in the wizard and Reasonix's global .env is updated then.
func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv string, keyEnvTyped bool, apiKey string) (providerPromptResult, error) {
fmt.Println()
if baseURL == "" {
baseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, "")
@@ -1939,8 +1948,9 @@ func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey s
if modelName == "" {
return providerPromptResult{}, fmt.Errorf("model name is required")
}
draft := apiKeyEnvFromProviderName(providerName)
if keyEnv == "" {
keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
keyEnv, keyEnvTyped = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft)
} else if !config.IsValidCredentialKey(keyEnv) {
return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
}
@@ -1952,7 +1962,7 @@ func promptCustomProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey s
Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+modelName)))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, draft), nil
}
// promptCustomProviderFromURL tries the OpenAI-compatible GET /models
@@ -1968,7 +1978,8 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
return providerPromptResult{}, fmt.Errorf("base URL is required")
}
providerName := providerSlug("custom", baseURL)
keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(providerName))
draft := apiKeyEnvFromProviderName(providerName)
keyEnv, keyEnvTyped := promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, draft)
apiKey := askSecret(in, os.Stdout, i18n.M.CustomPromptAPIKey)
fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.FetchingModelsFmt, "custom")))
@@ -1981,7 +1992,7 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
} else {
fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.CustomFetchEmpty))
}
return promptCustomProviderManualWith(in, baseURL, keyEnv, apiKey)
return promptCustomProviderManualWith(in, baseURL, keyEnv, keyEnvTyped, apiKey)
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), "custom")))
@@ -2002,7 +2013,7 @@ func promptCustomProviderFromURL(proxy netclient.ProxySpec) (providerPromptResul
Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.CustomAddedFmt, entry.Name+"/"+selected[0])))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, draft), nil
}
// promptAnthropicProvider handles the Anthropic compatible provider entry flow.
@@ -2022,14 +2033,14 @@ func promptAnthropicProvider(proxy netclient.ProxySpec) (providerPromptResult, e
// promptAnthropicProviderManual handles manual model entry.
func promptAnthropicProviderManual() (providerPromptResult, error) {
return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", "")
return promptAnthropicProviderManualWith(bufio.NewScanner(os.Stdin), "", "", false, "")
}
// promptAnthropicProviderManualWith is the shared backend for manual entry
// of an Anthropic-compatible custom provider. Pre-filled values (baseURL,
// keyEnv, apiKey) are reused as-is when non-empty so the URL-fetch flow
// can fall through to manual entry without re-asking the user.
func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKey string) (providerPromptResult, error) {
func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv string, keyEnvTyped bool, apiKey string) (providerPromptResult, error) {
fmt.Println()
if baseURL == "" {
baseURL = ask(in, os.Stdout, i18n.M.AnthropicPromptBaseURL, "")
@@ -2042,7 +2053,7 @@ func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKe
return providerPromptResult{}, fmt.Errorf("model name is required")
}
if keyEnv == "" {
keyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
keyEnv, keyEnvTyped = promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
} else if !config.IsValidCredentialKey(keyEnv) {
return providerPromptResult{}, fmt.Errorf("invalid API key variable name %q", keyEnv)
}
@@ -2054,7 +2065,7 @@ func promptAnthropicProviderManualWith(in *bufio.Scanner, baseURL, keyEnv, apiKe
Model: modelName, APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+modelName)))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, "ANTHROPIC_API_KEY"), nil
}
// promptAnthropicProviderFromURL tries the OpenAI-compatible GET /models
@@ -2070,7 +2081,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
if baseURL == "" {
return providerPromptResult{}, fmt.Errorf("base URL is required")
}
keyEnv := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
keyEnv, keyEnvTyped := promptAPIKeyEnvName(in, os.Stdout, i18n.M.AnthropicPromptKeyEnv, "ANTHROPIC_API_KEY")
apiKey := askSecret(in, os.Stdout, i18n.M.AnthropicPromptAPIKey)
fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchingModelsFmt, "anthropic")))
@@ -2083,7 +2094,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
} else {
fmt.Fprintf(os.Stderr, " %s\n", dim(i18n.M.AnthropicFetchEmpty))
}
return promptAnthropicProviderManualWith(in, baseURL, keyEnv, apiKey)
return promptAnthropicProviderManualWith(in, baseURL, keyEnv, keyEnvTyped, apiKey)
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicFetchModelsSuccessFmt, len(models), "anthropic")))
@@ -2104,7 +2115,7 @@ func promptAnthropicProviderFromURL(proxy netclient.ProxySpec) (providerPromptRe
Models: selected, Model: selected[0], APIKeyEnv: keyEnv, ContextWindow: askContextWindow(in, os.Stdout),
}
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.AnthropicAddedFmt, entry.Name+"/"+selected[0])))
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey), nil
return newProviderPromptResult([]config.ProviderEntry{entry}, keyEnv, apiKey, keyEnvTyped, "ANTHROPIC_API_KEY"), nil
}
func groupByFamily(providers []config.ProviderEntry) ([]string, map[string][]int, map[string]providerFamily) {
+8 -3
View File
@@ -1838,6 +1838,7 @@ func TestPromptCustomProviderManualDefaultsKeyEnvFromBaseURL(t *testing.T) {
bufio.NewScanner(strings.NewReader("sensenova-chat\n\n\n")),
"https://token.sensenova.cn/v1",
"",
false,
"",
)
if err != nil {
@@ -1857,6 +1858,7 @@ func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
bufio.NewScanner(strings.NewReader("manual-chat\n\n")),
"https://token.sensenova.cn/v1",
"CUSTOM_API_KEY",
true,
"",
)
if err != nil {
@@ -1874,14 +1876,14 @@ func TestPromptCustomProviderManualPreservesExplicitKeyEnv(t *testing.T) {
func TestPromptAPIKeyEnvNameRejectsModelName(t *testing.T) {
i18n.DetectLanguage("en")
var out bytes.Buffer
got := promptAPIKeyEnvName(
got, typed := promptAPIKeyEnvName(
bufio.NewScanner(strings.NewReader("grok-4.5\n\n")),
&out,
i18n.M.CustomPromptKeyEnv,
"CUSTOM_API_YAIROUTER_COM_API_KEY",
)
if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" {
t.Fatalf("key env = %q, want generated default", got)
if got != "CUSTOM_API_YAIROUTER_COM_API_KEY" || typed {
t.Fatalf("key env = %q (typed %v), want the untyped generated default", got, typed)
}
if text := out.String(); !strings.Contains(text, "not a valid API Key variable name") || !strings.Contains(text, "do not enter a model name") {
t.Fatalf("validation guidance missing from prompt output: %q", text)
@@ -1893,6 +1895,7 @@ func TestPromptCustomProviderManualAsksForModelBeforeCredentialName(t *testing.T
bufio.NewScanner(strings.NewReader("grok-4.5\ngrok-4.5\n\n\n")),
"https://api.example.com/v1",
"",
false,
"",
)
if err != nil {
@@ -1914,6 +1917,7 @@ func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testi
bufio.NewScanner(strings.NewReader("grok-4.5\n")),
"https://api.example.com/v1",
key,
true,
"same-secret",
)
if err != nil {
@@ -1929,6 +1933,7 @@ func TestPromptCustomProviderStagesExplicitKeyEvenWhenProcessEnvMatches(t *testi
bufio.NewScanner(strings.NewReader("grok-4.5\n")),
"https://api.example.com/v1",
key,
true,
"new-secret",
)
if err != nil {
Loaded 100 of 460 files, more files were not shown because too many files have changed in this diff. Show more