Skip to content

Merge develop into main - #52

Merged
linletian merged 51 commits into
mainfrom
develop
Aug 12, 2026
Merged

Merge develop into main#52
linletian merged 51 commits into
mainfrom
develop

Conversation

@linletian

Copy link
Copy Markdown
Owner

Summary

Merge latest develop into main (213 commits ahead).

Key changes since v0.3.0:

Validation

  • gofmt check passed
  • go test ./... passed
  • go build -o myworktree ./cmd/myworktree passed
  • go build -o mw ./cmd/mw passed

linletian and others added 30 commits May 26, 2026 22:25
- effective head calculation: prefer origin if ahead > local, otherwise
  pick the most-ahead remote among non-origin remotes
- aheadCount via git rev-list --count local..effective
- branchExists check for develop branch detection
- CheckDiverged returns DivergedResult map per upstream (main/develop)
- register GET /api/worktrees/diverged (batch) and GET /api/worktree/diverged (single)
- handleWorktreesDiverged: iterate all worktrees, call CheckDiverged per branch
- handleWorktreeDiverged: resolve worktree path by id, reuse status handler logic
- main worktree always gets empty DivergedResult (isMain branch returns early)
- CurrentBranch failure returns 500 instead of silent empty result
feat: add branch divergence detection with diverge badge and API handlers
Render a small GitHub Mark icon to the right of the sidebar main
workspace project name when the main repo's git remote resolves to
github.com. Clicking opens the canonical https://github.com/<owner>/<repo>
URL in a new tab via <a target="_blank" rel="noopener noreferrer">,
with event.stopPropagation() so the icon click does not trigger the
row's selectWorktree handler.

Backend:
- Add gitx.GitHubURL(root) resolver. Prefers 'origin', then iterates
  'git remote'. Normalizes SCP / HTTPS / ssh:// forms, strips .git and
  trailing slashes, and returns '' for any host other than github.com
  (case-insensitive). GitHub Enterprise and non-Git remotes are
  intentionally not surfaced.
- /api/main response gains a github_url string field (always present;
  empty when no link can be resolved).

Tests cover all three URL formats, malformed inputs, GitHub Enterprise,
non-GitHub hosts, .git suffix, and the /api/main field plumbing.

Docs (per docs-first convention): CHANGELOG, API, ARCHITECTURE, PRD,
README.md, README.zh-CN.md.
The githubSCPRE regex hardcoded 'git@github.com:...' as the only
recognized SCP-style prefix. Repositories whose origin URL uses a
non-'git' username (e.g. a '\~/.ssh/config' alias mapping
'github.com' to 'User mywork', or a CI bot configured as
'ci-bot@github.com:...') would silently fail to surface a GitHub
icon in the sidebar — the URL was still valid, but the resolver
returned '' so the icon was never rendered.

Loosen the regex to '[^@/]+@github\.com:...' so it accepts any
non-empty user segment, mirroring the existing githubSSHRE pattern
and bringing the two ssh-shaped forms back into symmetry.

- remote.go: relax githubSCPRE; update Go doc to mention arbitrary
  user and replace the misleading 'default SSH port only' line.
- gitx_test.go: add three new parse cases (ssh config alias user,
  CI bot user, dotted user) so the relaxation is covered.
- docs/API.md: update the supported URL formats list to call out
  the arbitrary user segment.

No behavior change for existing 'git@' URLs; tests + vet + build
all green.
Eliminate ~100,000× disk write amplification caused by the old
enforceMaxLogSize loop (read 10MB + truncate + write 10MB per 1024-byte
PTY chunk). Under TUI redraw workloads, this could generate 280MB+ of
accumulated disk writes in the data directory.

Changes:
- Add RingBuffer (internal/instance/logbuf.go): bounded, thread-safe,
  in-memory ring buffer. Backing slice pre-allocated eagerly. Hot-path
  optimized with WriteString (no []byte(chunk) copy) and atomic pointer
  access (no stateMu per chunk).
- Add adaptive sizing (internal/instance/sizing.go): per-instance cap
  clamp(available/16, 16MB, 256MB), default 32MB. Global budget capped
  at 25% system RAM. User override via config.GlobalConfig.LogBufferBytes.
- Refactor Manager (internal/instance/manager.go): pumpLogs writes to
  ring buffer instead of disk. Tail/ReadSince read from buffer. Removed
  enforceMaxLogSize, logPathByID, and all LogPath references.
- Remove LogPath from ManagedInstance (internal/store/state.go).
  Old state.json files with log_path load cleanly (JSON silently ignores).
- Add PurgeOrphanLogFiles() called at daemon startup to clean up dead
  .log artifacts left by pre-buffer code.
- HTTP/MCP: budget-exceeded returns 503 with structured
  log_buffer_budget_exceeded body. UI shows dedicated modal.
- Test coverage: RingBuffer (15 tests), sizing/budget (9 tests),
  manager budget integration, backward-compat state loading.
- Docs: PRD, ARCHITECTURE §4.1, API (error shape + cursor semantics),
  CHANGELOG Unreleased section, and detailed implementation plan.

BREAKING: state.json schema — log_path field removed. Old files
continue to load; external tooling should drop log_path dependency.
- Stats API now includes daemon_cpu_percent/daemon_memory_bytes in global totals
- Per-instance stats expose memory_buffer_bytes (used) and memory_buffer_cap_bytes (cap)
- Collector refactored: system calls moved outside lock, 3-phase pipeline
- UI adds mw daemon row and buffer used/cap annotation per instance
- Add .omo/ to .gitignore for OpenCode internal session data
Halves the per-second process.NewProcess() syscall storm when the
resource monitor modal is open, reducing daemon CPU overhead.
fix: replace per-instance disk log with in-memory ring buffer
Add Diff and Content APIs, preview.html, highlight.js/marked.js vendors, security helpers, and 16 test cases. Based on docs/plans/text-file-preview/.
…ked diff

Build on the text file preview feature with three iteration-driven
additions (FEASIBILITY/TASK updated to record design deviations):

- preview.html: per-line flex containers with line-number gutter; hljs
  splitHighlightedLines tracks unclosed <span> across lines so
  multi-line tokens (block comments, raw strings) keep their colour.
  Diff keeps hljs token-level text + CSS row-level background.
- app.go: pass -c core.quotePath=false to every git call and add
  unquoteGitPath as defense-in-depth, so non-ASCII paths survive
  core.quotePath quoting in numstat, status, and diff outputs.
- app.go: handleWorktreeFileDiff synthesises a full unified diff when
  git returns empty (untracked file), with Stat pre-check + post-build
  re-check to honour the 500KB cap and \ No newline at end of file.
- app_test.go: TestUnquoteGitPath (12 cases incl. UTF-8), plus
  Synthetic / SyntheticNoNewline / SyntheticTooLarge for the new path.
- docs/API.md: document the two endpoints and the synthetic diff
  behaviour so external consumers know what to expect.
gofmt -l flagged both files for missing final newline.
Pure whitespace fix; no behavioural change.
feat(preview): line numbers, quotePath handling, synthetic untracked diff
# Conflicts:
#	CHANGELOG.md
#	docs/PRD.md
#	internal/app/app_test.go
#	internal/ui/static/index.html
PR #40 (GitHub sidebar link) and PR #42 (branch divergence detection) both
inlined a ~10-line `git remote` parser in their respective files. Git's
content-level merge cannot detect duplicate top-level symbols across files,
so the collision only surfaced as a Go compiler error after the merge. The
fix was to drop the divergent.go copy in the merge commit, leaving a single
implementation in remote.go.

This commit goes one step further: move that single implementation to a
new remotes.go file and export it as ListRemotes, so future features that
need the same helper have a clearly signposted canonical entry point
instead of being tempted to inline their own copy.

No behavior change. The two existing callers (GitHubURL in remote.go and
effectiveHead in diverged.go) now invoke ListRemotes directly.
`gofmt -l` on Linux/macOS CI rejects the file without a trailing
newline. Run gofmt to comply.
feat: surface github.com link in sidebar main workspace
整合 reasonix web UI 嵌入调研(方案 A 反代+iframe 推荐)+ 三项实测结论:
- session lease 按 session 文件路径互斥,多 worktree 各起一个 serve 可并行
- 端口以 --port-file 为权威;serve 无自动递增、web 自动 +1
- /events 每 15s 自带 : ping 保活;Cookie: reasonix_token 注入实测 200 无重定向
- 布局问题:侧栏 220px 固定,需反代 + 轻量注入解锁折叠(§9)
Add a second instance type: kind=reasonix runs a per-worktree
'reasonix serve' subprocess and renders its web chat UI in an iframe
via a same-origin reverse proxy at /rx/<id>/.

- store: ManagedInstance.Kind (tty|reasonix, omitempty, no migration)
- internal/instance/reasonix: driver with isolated REASONIX_HOME,
  ~/.reasonix config/.env symlinked in, kernel-assigned port via
  --port-file, fixed session file, cross-platform liveness
  (kill(pid,0) + TCP probe, no /proc), SIGTERM->SIGKILL stop
- manager: kind branch for Start/Stop/Restart/Delete; Reconcile
  re-attaches live serve processes on startup (R-02); Delete stops
  before wiping state dir (N-01)
- internal/app/reasonix_proxy: /rx/<id>/ reverse proxy with
  reasonix_token cookie injection, RawQuery stripped, Accept-Encoding
  forced to identity, SSE streaming, HTML URL-prefix rewrite for
  fetch/EventSource/XHR
- frontend: iframe rendering branch, create-dialog checkbox, rx badge
- docs: API §5.10, ARCHITECTURE §3.2/4.2, PRD §7, plans index +
  FEASIBILITY §9.5 security tradeoff, DEFERRED notes

Two rounds of code review applied (R-01..R-08, N-01..N-06, T-01..T-03);
follow-ups tracked in issues #43..#49. Pre-existing app test failures
(TestHandleInstanceUpdate/Reorder, real-user state dir) tracked in #43.
…r-side

Runtime issues found when exercising the MVP:

- iframe reloaded every 2s poll tick: renderWorkspace() -> ensureWebFrame()
  assigned frame.src unconditionally; guard it with a dataset.instance
  check so the embedded page only navigates when the instance changes
  (trailing slash added so in-page relative URLs resolve under /rx/<id>/).
- <img src="/assets/logo-wordmark.svg"> 404'd: the URL-prefix shim only
  covered fetch/EventSource/XHR. Rewrite root-relative src/href/action/
  poster attributes server-side in ModifyResponse (rewriteRootAttrs) so
  the browser never issues a wrong first request, and keep a
  MutationObserver for dynamically inserted nodes.

rewriteRootAttrs (two-level tag->attr regex):
- all attributes in one tag rewritten (multiple-attrs case)
- case-insensitive (?i), unquoted values, idempotent (already-prefixed and
  protocol-relative //host URLs skipped), data-src/xlink:href and JS
  property assignments (el.src=) not matched
- documented regex tradeoffs (value containing '>' truncates; no second
  pass for initial page; CSS url()/srcset/SVG <use> uncovered)

Tests: TestRewriteRootAttrs grown to 13 cases incl. the previously
uncovered risk path (two attrs in one tag), verified via control
experiment; proxy HTML-injection test now asserts real rewritten output
instead of grepping for script source.
linletian and others added 11 commits August 11, 2026 15:51
GitHub macOS runners have broken/unresponsive DNS, and
HTTPServer.server_bind() calls socket.getfqdn() for a reverse lookup
that hangs indefinitely. The fake reasonix serve scripts (python3
http.server) never wrote --port-file, so every reasonix test timed
out with 'serve did not become ready within 15s' on macOS only.
Patch socket.getfqdn to skip the DNS lookup in the test scripts.
feat(reasonix): embed reasonix web chat UI as an instance kind (MVP)
Finish every open reasonix issue plus the deferred env/preStart injection:

- #44 security: independent loopback listener serves only /rx/, iframe
  loads the cross-origin web_url from /api/instances so embedded chat
  content can no longer silently call the management API; TLS mode falls
  back to the same-origin /rx/ route (documented in DEFERRED.md).
- #48 layout: single HTML injection point adds desktop sidebar collapse
  (collapsed by default, own toggle button, --mw-sidebar-w width),
  narrow screens keep the native mobile UI.
- #46 perf: driver caches {port, token} for the instance lifetime
  (Start writes, Stop/Cleanup drop) so the proxy path reads no files.
- #47 locking: Delete and startReasonix failure paths run Reasonix.Stop/
  Cleanup outside stateMu (5s worst case no longer stalls the manager).
- #45 robustness: pre-start 'reasonix --version' gate (>= 1.22.0),
  readiness errors carry serve.log tail, reasonix.CookieName single
  source; fixes a latent Start deadlock on early serve exit.
- #43 tests: app handler tests use isolated temp data dirs + seeded
  state, reorder passes the optimistic-lock version (new 409 case).
- deferred env/preStart: StartInput.Env + PreStart hook, tag env/
  preStart flow into the serve process (command still ignored).
- #49 closed as intended: Restart stays a brand-new conversation.

Docs (API/ARCHITECTURE/PRD/DEFERRED/README) and full test suite updated;
all tests pass, all 7 issues closed.
Upstream .app is a 2x2 grid whose .transcript/.footer rely on
auto-placement: .sidebar (grid-row:1/3) claims column 1, pushing them to
column 2. display:none on the collapsed sidebar reflows auto-placement —
.transcript lands in the 0px column (chat area vanishes) while .footer
stretches across row 1 (only the input bar stays visible). Pin
.mw-rx .transcript{grid-column:2;grid-row:1} and .mw-rx .footer{grid-column:2;grid-row:2}
so collapsed layout keeps upstream's column-2 row-1/row-2 slots.
…twork-open

The independent #44 listener reports an absolute http://127.0.0.1:<port>
web_url, which a remote browser resolves to ITSELF — with the default
0.0.0.0 listen (or an explicit LAN IP) the reasonix iframe was blank on
LAN access. Enable the independent listener only when the main listener
is loopback-only (and non-TLS); network-open or TLS modes fall back to
the relative same-origin /rx/<id>/ path, which follows the browser's
current origin, so LAN/remote access works and switching IPs after
startup keeps working. Frontend needed no change (web_url empty → it
already falls back). Docs (API/ARCHITECTURE/DEFERRED/README) updated;
new TestReasonixNonLoopbackFallsBack covers the fallback.
Behavior parity with tty instances: a tty shell dies when its PTY hangs
up on process exit, but a reasonix serve has no PTY and previously
survived shutdown as an orphan (re-attached by Reconcile on next start).
Server.Shutdown now calls Manager.StopAllReasonix before closing the HTTP
servers: every running reasonix serve gets a graceful Stop (TERM→KILL),
state dirs are kept so the next Start --resumes the same session.jsonl
and the conversation survives. kill -9 still bypasses Shutdown — the
serve then lingers and Reconcile re-attaches it (documented). Tests:
TestStopAllReasonix, TestServerShutdownStopsReasonix.
…tart lock, encoding negotiation

- proxy: strip only myworktree's ?token= and forward other query params
  (e.g. ?session=) intact to the reasonix backend
- proxy: force Accept-Encoding identity only on HTML navigation requests;
  explicitly negotiate gzip for subresource/API so Go's Transport forwards
  compressed bodies verbatim; ModifyResponse refuses to splice into a
  still-compressed HTML response
- driver: serialize Start per instance id (in-flight lock) so concurrent
  Starts cannot both spawn serve processes racing on the same port/pid
  files; second caller sees the healthy process and returns idempotently
- driver: remove stale pid file alongside port file before spawning so
  waitReady cannot pair an old pid with the new port
- tests: query pass-through, encoding negotiation, compressed-HTML guard,
  concurrent-Start lock (verified it fails without the lock); instanceView
  JSON round-trip intent documented
feat(reasonix): complete native-ui backlog (#43-#49) + LAN & shutdown fixes
@linletian

Copy link
Copy Markdown
Owner Author

Code Review — PR #52 (Merge developmain)

Review scope: 52 files, +10121/−432, base 4227f41 → head b3b7b65.
Verification: go build ./... ✅ · go vet ./internal/... ✅ · go test ./... ✅ (app / instance / reasonix / gitx / ui / portal all pass).

总体评价

合并 PR 的整体质量很高,尤其值得肯定:

  • RingBuffer 并发正确:全部字段在 mu 下读写;dropBufferLockedSwap(nil)pumpLogsLoad() 无竞争,Close 后写入被丢弃且无越界。
  • 锁序无死锁:唯一嵌套是 DeletestateMu→mu,全仓库无反向 mu→stateMu,不成环。
  • totalBufBytes 增减严格配对(Start 失败路径与 drop 路径都正确)。
  • reasonix driver 的 per-instance Start 锁、version gate(v1.22.0)、token/symlink 处理、Stop/Health/Cleanup 生命周期一致;reasonixProxy 剥离 myworktree 的 ?token=、强制压缩协商、防 HTML 重写都处理到位。
  • 双保险路径处理:core.quotePath=false + unquoteGitPath

但存在 1 处高危 XSS 建议合并前修复,另有若干中/低危项。


🔴 高危

1. 文件预览 Markdown 存储型 XSS(internal/ui/static/preview.html:492

content.innerHTML = '<div class="markdown-body">' + marked.parse(text, {sanitize: true}) + '</div>';

marked 为 v12.0.2,sanitize 选项已在 v8 移除,此调用被静默忽略(vendor 包内无 sanitize 标识)。markdown 中的原始 HTML 会被原样写入 innerHTML<img onerror=...><svg onload=...><details ontoggle=...>[x](javascript:...) 均可执行。预览页与主 UI 同源,可静默调用 /api/* 携带会话。

威胁模型:worktree 内容由 agent 产出(不可信),恶意 .md 文件一被预览即触发。

建议DOMPurify.sanitize(marked.parse(text));或对 .md 直接走 renderWithLineNumbers(textContent 路径);至少给 /preview 加 CSP。


🟠 中危

2. diverge badge 属性注入 XSS(internal/ui/static/index.html:1965

badges += `<span class="wt-diverge-badge-err" title="Error checking ${branch}: ${info.error}">err↑</span>`;

info.error 来自 git stderr(diverged.go%v/%q 回显),未转义拼进 title。git 分支名合法允许 " < > &,恶意分支名可经 git 报错回显闭合 title 注入事件属性。建议 escapeHtml(info.error) 或改用 createElement + textContent

3. 文件预览可经 symlink 逃逸 worktree 根(internal/app/app.go handleWorktreeFileContent / handleWorktreeFileDiff

isPathWithin 只做词法 filepath.Abs 前缀检查,不解析符号链接。worktree 内 notes.txt -> ~/.ssh/id_rsa 这类 symlink 会被 os.ReadFile 跟随读取(isSensitiveFile 只按文件名/关键字过滤,notes.txt 不命中)。建议 Lstat 拒绝 symlink,或 EvalSymlinks 后复查 isPathWithin。另:X-File-FullPath 响应头会向客户端泄露宿主绝对路径。

4. 日志缓冲预算 TOCTOU(internal/instance/manager.go ~334/360)

预算检查 totalBufBytes.Load()Add(capBytes) 非原子,并发 Start 可整体超 25% RAM 上限(最多超一个实例的 cap)。影响有界,可用短 mutex/自旋收敛。

5. 并发 Restart 竞态(internal/instance/manager.go Restart

两个并发 Restart 同一实例都会通过 status 检查、各自 Start 出新 id → store 两条记录、两个进程、双份 buffer(reasonix 场景会起两个 serve)。HTTP 层无串行化。

6. Restart 预算双计(internal/instance/manager.go Restart

m.Start 的预算检查发生在旧 buffer drop(dropBufferLocked之前,重启瞬间 totalBufBytes 含新旧两份,预算吃紧时合法 Restart 会被自身旧 buffer 拒绝。应先 drop 旧 buffer 或按增量计。


🟡 低危

  • Restart 忽略 store 保存错误manager.go:838 _ = m.Store.SaveWithVersion(...)):保存失败仍继续内存/进程清理,store 与运行态永久不一致。
  • 无 CSP/preview/rx/、主 UI 大量内联脚本/onclick,没有 Content-Security-Policy/frame-ancestors(若修掉 UI improvements: faster load, better import, clearer stopped/offline states #1/feat: repo-stable-port and server-revision detection #2,此为纵深防御)。
  • reasonix iframe 无 sandbox:默认网络开放(回退同源 /rx/)时,reasonix 页面与 API 同源,其 JS 可携会话调 /api/*。DEFERRED §6 已列为已知取舍,仍建议 sandbox="allow-scripts"
  • 独立 /rx/ 监听无鉴权/无 CSRFrxSrv127.0.0.1)不经过 withAuth,用户访问任意网页可对该端口发起跨站请求(token 由代理注入,攻击者读不到,但可触发 GET 类动作)。与主监听 loopback 无 token 时姿态一致,值得记录。
  • Reasonix==nil 时 Stop reasonix 只标 stopped 不杀进程manager.go:657)——历史记录残留时进程成孤儿。
  • portal ready 通道被 close 但无消费方portal.go:205,全仓无 <-p.ready)——死代码/未完成信号。

测试缺口

新后端(logbuf、sizing、reasonix driver/proxy、gitx diverged)测试覆盖很好,但新安全边界零覆盖(grep 无命中):

handleWorktreeFileDiff / handleWorktreeFileContent / isPathWithin / isSensitiveFile / unquoteGitPath / handleWorktreesDiverged / writeLogBufferBudgetErr

路径越界与敏感文件过滤正是这次新功能的安全边界,建议补测试(含 symlink 逃逸用例)。


建议

  1. 合并前必修UI improvements: faster load, better import, clearer stopped/offline states #1(preview markdown XSS)——一行 + DOMPurify,或直接降级为文本渲染。
  2. 应尽快修feat: repo-stable-port and server-revision detection #2 转义、chore: add CI workflow and comprehensive tests #3 symlink 逃逸、Prepare v0.1.0 release workflow #4 预算原子性、Document release binary usage in README #6 Restart 双计。
  3. 后续Release v0.1.0 #5 Restart 串行化、CSP、iframe sandbox、补新 handler 测试。

linletian and others added 9 commits August 12, 2026 13:33
- issue #53: hide the web frame (display:none) instead of destroying it on
  tab switch, so chat drafts / scroll / sidebar session list survive;
  re-navigate only when the resolved src changes (new port after restart)
- issue #54: hide leftover positioned xterm hosts when a reasonix tab is
  active so they cannot overlay/block the static iframe
- issue #55: reset terminal-container opacity/filter on switching to a
  reasonix instance so a leftover grayscale never greys out the web UI
- stop->start regression: invalidate the frame's src cache when an instance
  is stopped so the next start re-navigates instead of showing the stale
  pre-stop page
- add TestIndexHTMLCoversReasonixWebFrame regression test
…on pool (issue #56)

Each reasonix instance previously ran with an isolated REASONIX_HOME, so the embedded web UI never saw the project's existing history (sidebar empty, 'branches: none'). Requirement revision: myworktree should not intervene in the agent's product logic — the serve now uses the user's real ~/.reasonix, so sessions/history/config/credentials are shared per project exactly like a terminal-run reasonix, and the same project's history (including terminal CLI/TUI sessions) is visible/switchable in the sidebar. Cross-project isolation is reasonix's own (per-cwd).

- Remove home dir, config/.env symlinks, session.jsonl, --resume and the REASONIX_HOME env injection; state dir now only holds token/port/pid/serve.log.
- Strip inherited REASONIX_HOME/REASONIX_STATE_HOME from the serve env (log key names only) with tag-env opt-out; legacy home/ leftovers get a migration hint on Start.
- Cleanup never touches the shared ~/.reasonix pool; issue #49 'Restart = fresh session' semantics stay.
- Tests: cross-platform env-strip via fake-serve echo, same-cwd concurrent instances, removeEnv edge cases.
- Docs: PRD/ARCHITECTURE/DEFERRED/FEASIBILITY/README/CHANGELOG synced; upstream contract + owner recorded.
Add scripts/verify-reasonix-contract.sh as the single source of truth for
the upstream reasonix behavior contract the driver depends on (version
gate, per-cwd session layout, fresh-session uniqueness, lease refusal),
reachable via an opt-in go test (REASONIX_CONTRACT=1) and a new CI
workflow. Both CI paths SKIP (green) on runners without a reasonix
binary until an install step is added. Document the contract and the
REASONIX_HOME shared-pool behavior in README/ARCHITECTURE.
fix(reasonix): keep embedded web UI alive across tab switches
The injected hide/expand sidebar button in the embedded Reasonix web chat
was 34x34px at (8,8), so its right side overlapped the conversation. It is
now a vertical 24x64px pill at (2,8) showing a CSS arrow glyph that
switches with state (▶ collapsed / ◀ expanded, pointing at where the
sidebar moves — no text, no i18n). Measured against the upstream
.transcript padding (24px 28px on desktop), the button's right edge (26px)
stays inside the chat's 28px left gutter, so it never covers message text
in either state; the taller target is easier to see and click.

Layout tests now use grep-style per-property regexp assertions instead of
tight substring matches, so CSS property reordering no longer breaks them.
feat(reasonix): native-ui polish — session sharing, shutdown, UI fixes + CI/test (issues #43-#49, #56)
@linletian

Copy link
Copy Markdown
Owner Author

Code Review — PR#52 (develop → main)

合并总体功能很扎实(reasonix 嵌入、文本预览、git 分歧徽章、monitor 增强),但合并到 main 前建议处理下面几个风险点,按严重程度排序:

高风险

1. preview.html 的 marked XSS(很可能存在)

  • internal/ui/static/preview.html:492 调用 marked.parse(text, {sanitize: true})
  • marked v5+ 移除了 sanitize 选项,参数会被静默忽略
  • 当前 vendor 进来的 marked.min.js 如果是新版,markdown 里的 <img src=x onerror=...> 会原样渲染
  • 服务端没做任何过滤 → 任何能打开预览的用户都能在你页面里执行 JS
  • 建议:在浏览器里手动验证 <img src=x onerror=alert(1)> 的渲染;不生效就换成 DOMPurify,或干脆在服务端预处理掉 <script> / on* 事件

2. AuthToken 通过 URL query 传递 — 会泄漏到日志

  • internal/app/app.go:2096internal/portal/portal.go:770 都接受 ?token=xxx
  • token 会出现在:反向代理 access log、浏览器历史、第三方资源 Referer
  • 密码只该走 Authorization header 或 HttpOnly cookie
  • 建议:保留 URL token 仅作一次性登录跳转(302 → 带 cookie),其他位置只认 Authorization / cookie

3. reasonix proxy 丢弃 RawPath,非 ASCII URL 会破坏

  • internal/app/reasonix_proxy.go:73req.URL.RawPath = "",紧接着 req.URL.Path = "/" + rest
  • 如果 reasonix 任何 path 段含中文/特殊字符(comments、文件路径、搜索 query),转给上游时会双重解码或解码错误
  • 建议:用 req.URL.EscapedPath() 配合 SplitRawSegments 之类的方式,不要粗暴清空

中风险

4. reasonix Stop 用进程组信号可能漏杀子进程

  • internal/instance/reasonix/driver.go:580syscall.Kill(-pid, SIGTERM) 给整个进程组发信号
  • pid 是从上游 reasonix 的 pid file 读的(注释也承认),可能不是 myworktree exec.Cmd 起的那个
  • 如果上游 serve fork 了 wrapper 而 wrapper 没 setpgid,子进程漏杀 → 端口占用 / 残留
  • 5 秒后 SIGKILL 同样会漏
  • 建议:fallback 用 pgrep -P pid 或遍历 /proc/<pid>/task/<tid>/children 再杀一遍

5. Portal 注册/状态写入错误被吞

  • internal/portal/portal.go:480 data, _ := json.Marshal(reg) 错误忽略;os.WriteFile 错误也忽略
  • writePortalStatus(行 509)、deleteRegistration(行 486-495)、cleanupStaleRegistrations(行 433-437)同样
  • 磁盘满 / 权限错 / 目录被删 → 注册悄无声息失败,portal 选举和列表全错乱
  • 建议:每个写操作至少 log.Printf 错误

6. CSRF token 生成的检查有 race

  • internal/portal/portal.go:836-851:先解锁、再生成 token、再加锁写 map
  • 两个并发请求都看到 len(s.used) < 10000,都生成 token → 实际超过 10000
  • 影响小(上限变成 ~20000),但属于明显 race
  • 建议:把 len(s.used) 检查移到生成 token 的同一把锁里

7. tailscale 相关代码全量保留但禁用 — 死代码

  • internal/portal/portal.go:262-401 一堆 tailscaleServeLoop / ensureTailscaleServe / repairTailscaleServe / tailscaleStatus 等函数全无调用方
  • 注释说等 macOS tailscale 修好再启用,但这些代码没有测试也不会被 CI 触及
  • 文件本身还混合了仍在用的 tsDNSNameCmd / TailscaleDNSName,状态混乱
  • 建议:挪到 //go:build tailscale tag 后面,或开 internal/portal/tailscale_disabled.go 文件

低风险 / 观察项

8. diverged.go 写死了 "develop" 分支名

  • internal/gitx/diverged.go:25 直接比较 worktreeBranch == "develop"
  • 项目约定分支不是 develop(比如 dev / integration),整条分歧检测对其他分支就退化成只查 main
  • 建议:把这个名字做成配置项(gitx 调用方传入),或者从 git symbolic-ref refs/remotes/origin/HEAD 推断

9. Portal getIP 处理 X-Forwarded-For 边界

  • internal/portal/portal.go:780-787:先用 , split,再用 split
  • IPv6 没分隔符所以勉强 OK,但代码读起来很怪
  • 建议:直接 strings.Split(xff, ",")strings.TrimSpace(part0),更清晰

10. csrfState.authLimits 清理窗口长于限速窗口

  • internal/portal/portal.go:911 cleanup 用 5 分钟 cutoff
  • allowAuthAttempt 用 1 分钟窗口过滤,剩 4 分钟垃圾占内存
  • 触达 len(s.authLimits) >= 10000 后所有 IP 被拒,要等 cleanup 跑才能恢复
  • 建议:cleanup 窗口改成 1 分钟(或限速窗口改成 5 分钟)保持一致

合并前必看

  1. 验证 vendor 的 marked.min.js 是否还认 sanitize 参数(PR 没改 vendor 文件,看一眼 commit history 确认版本)
  2. 决定 URL ?token= 走留还是走 — 留着就得在文档里警示用户别把 access log 暴露出去
  3. Portal 写入加一行 error log,避免线上排障时一脸懵

其余都是 nice-to-have,可以 merge 后再补。

…y, lock cleanup)

- PATCH /api/instances and POST /api/instances/restart responses now render
  through instanceView, so reasonix instances keep their web_url like GET/POST
- instanceView builds the response map directly, dropping the per-call
  Marshal->Unmarshal round-trip
- preStart env now strips inherited REASONIX_HOME/REASONIX_STATE_HOME exactly
  like serve (shared reasonix.RemoveEnv), keeping both phases on the same
  ~/.reasonix home; host-exported overrides still apply via tag env
- Manager.Stop tears down the per-instance serve-management dir and Start
  lock (Cleanup) after stopping, since the id is never reused; the driver's
  starts map no longer grows across start/stop cycles
- delete confirmation copy corrected to "Delete instance?"
- tests: PATCH web_url, Cleanup drops Start lock, preStart env strip,
  Stop cleans management dir; Restart cleanup test adapted (Stop now
  pre-cleans, seeds a stale dir instead)
@linletian
linletian merged commit 11f4129 into main Aug 12, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant