Compare commits

..

18 Commits

Author SHA1 Message Date
Yaojia Wang
ca9eaa8f1f docs: mark all of Wave 5 done (fan-out, access token, android parity)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-13 05:59:29 +02:00
Yaojia Wang
bc31de85dd feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)
Brings the native Android client to parity with the web/iOS Projects + git surface,
consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules"
premise was stale; they were already online.)

- :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders,
  safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders
  (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/
  gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body;
  writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)).
- :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push;
  new WorktreeViewModel (create/remove/prune + client branch validation + isMain block);
  ProjectDetailViewModel failure-isolated PR-chip + recent-commits.
- Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits,
  worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file
  stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the
  pre-existing "no inbound link to the diff screen" gap.

Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify`
BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held;
git status android-only. Compose rendering/interaction deferred to on-device QA
(android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
2026-07-13 05:58:28 +02:00
Yaojia Wang
469037cb94 feat(auth): optional WEBTERM_TOKEN access-token gate (W5)
An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.

- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
  bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
  parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
  HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
  central authGate over all remote HTTP require the cookie. Open: /login, /auth.
  Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
  browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).

Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
2026-07-13 05:25:07 +02:00
Yaojia Wang
9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00
Yaojia Wang
c81821b890 docs(plans): Wave 5 implementation plans (fan-out board, access token, android parity) 2026-07-13 04:37:24 +02:00
Yaojia Wang
6541246fc9 docs(roadmap): mark Wave 1-4 done (8 shipped), leave Wave 5 open 2026-07-13 04:25:16 +02:00
Yaojia Wang
a7eba2d43b docs(progress): log the Wave 1-4 roadmap batch (8 features, multi-agent plan+build+verify) 2026-07-12 22:10:57 +02:00
Yaojia Wang
19f241d7a3 feat(git): stage / commit / push from the diff viewer (W4)
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).

- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
  throws). Path containment: every files[] entry realpath-contained under the repo,
  argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
  single -m argv. Push: current branch only — has-upstream → plain `git push`;
  no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
  ≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
  client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
  fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
  GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
  + isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
  (all text via textContent; re-loads the diff on success).

Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
2026-07-12 22:09:43 +02:00
Yaojia Wang
552f35c690 feat(projects): worktree remove + prune from any device (W4)
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:

- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
  rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
  (1) target realpath must match an entry git itself reports in `git worktree list`
  for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
  invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
  containment on request + each list entry, operating on git's canonical path (+ --);
  (4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
  classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
  requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
  + a prune button; force needs an explicit second confirm (no single-click data loss).

Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
2026-07-12 21:44:23 +02:00
Yaojia Wang
1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00
Yaojia Wang
7551f8a4b2 feat(projects): PR + CI/checks status chip via gh (W3)
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.

- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
  mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
  isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
  CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
  degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
  Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
  (default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
  chip mounted in the project detail header (git repos only).

Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
2026-07-12 21:01:18 +02:00
Yaojia Wang
b119c31019 feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.

- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
  attacker-supplied base never reaches a shell or acts as a git option:
  (1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
  chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
  <base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
  (3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
  divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
  disables Working/Staged tabs in base mode. public/projects.ts derives bases from
  the worktree branches ∪ current branch.

Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
2026-07-12 20:43:32 +02:00
Yaojia Wang
3076843e9c feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.

- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
  + a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
  byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
  new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
  (QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
  exists, hasn't exited, produced no output during settle, and is idle — three
  guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
  (⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.

Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
2026-07-12 20:27:37 +02:00
Yaojia Wang
e062065cd3 feat(cockpit): approval preview — command/diff above Approve/Reject (W1)
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.

- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
  Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
  Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
  200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
  ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
  PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
  textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
  plan gates fall back to today's name-only bar.

Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
2026-07-12 20:04:18 +02:00
Yaojia Wang
debf47d99e feat(links): clickable URLs + file:line paths in the terminal (W1)
Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.

- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
  a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
  while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
  window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
  addon default; a path link provider → openPath() POSTs {file,line} to
  /open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
  only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).

Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.

Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
2026-07-12 19:44:40 +02:00
Yaojia Wang
3e49e36806 docs(plans): implementation-ready plans for roadmap Wave 1-4 features (8, parallel-generated) 2026-07-12 19:24:21 +02:00
Yaojia Wang
09134e5001 docs: add ROADMAP.md — prioritized, grounded feature backlog in build-order waves 2026-07-12 19:07:13 +02:00
Yaojia Wang
8be2b06564 fix(projects): worktree-create sent repoPath but the server reads path (400)
The 'New worktree' form POSTed { repoPath, branch } to /projects/worktree, but
server.ts:706 and the documented contract (FEATURE_WALKAWAY_WORKBENCH FR-B3.1)
read body.path — so every browser worktree-create returned 400 'path and branch
are required' and git worktree add never ran. Send 'path' instead. The existing
worktree-form test had locked in the wrong key (asserted repoPath), which is why
it stayed green while the feature was broken; corrected to assert 'path'.
2026-07-12 19:03:53 +02:00
117 changed files with 15359 additions and 237 deletions

View File

@@ -65,6 +65,8 @@ npm test # unit tests (vitest, all modules)
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
## Architecture (the parts that span files)
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.

View File

@@ -66,6 +66,25 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
no upstream (fields absent).
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
(fail=red / pending=amber / pass=green).
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
log failure does NOT hide the rest of the detail (failure-isolated).
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
carry the sessionId; the gate is still visible in the terminal). MEDIUM.

View File

@@ -9,22 +9,20 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here)
## Build environment (SDK installed — all modules build)
The current build environment has **no Android SDK**. Everything that can be pure
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
against SDK 35/36.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
`:client-tls`, `:test-support`, `:transport-okhttp`.
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
To bring the Android modules online later: install an SDK, add
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
each stub.
Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`./gradlew test :app:assembleDebug koverVerify`.
## Module map (mirror of the iOS SPM packages — plan §3)
@@ -35,10 +33,10 @@ each stub.
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS
@@ -47,16 +45,16 @@ each stub.
### Dependency graph (arrows = "depends on")
```
:app (SDK-gated)
:app
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated)
│ │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
:wire-protocol ◀──────────── :transport-okhttp
└──────── :test-support → test source sets only
```

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
/**
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
*/
@Serializable
public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
)
/**
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
*/
@Serializable
public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
*
* - [Ok] — a 200 with the op's route-specific payload [T].
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
* variant (plan Edge cases / Security).
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
*
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
*/
public sealed interface GitWriteOutcome<out T> {
/** 200 — the op succeeded; [payload] is the route-specific success body. */
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
/** 429 — the server rate-limited this write. */
public data object RateLimited : GitWriteOutcome<Nothing>
}
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
@Serializable
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
@Serializable
public data class CommitResult(val commit: String = "")
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
@Serializable
public data class RemoveWorktreeResult(val path: String? = null)
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
@Serializable
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
@Serializable
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
/**
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
* (never throws — the caller already knows the status is 200).
*/
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
internal fun decodeGitError(bytes: ByteArray): String? =
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
*/
public enum class PrAvailability(public val wire: String) {
/** A PR exists for the current branch; the sibling fields are populated. */
OK("ok"),
/** gh works but the branch has no PR (or no remote/default repo). */
NO_PR("no-pr"),
/** `gh` binary not found on PATH (ENOENT). */
NOT_INSTALLED("not-installed"),
/** gh present but not logged in (needs `gh auth login`). */
UNAUTHENTICATED("unauthenticated"),
/** `GH_ENABLED=0` — feature off, never spawns gh. */
DISABLED("disabled"),
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
ERROR("error"),
;
public companion object {
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
public fun fromWire(wire: String): PrAvailability =
entries.firstOrNull { it.wire == wire } ?: ERROR
}
}
/**
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
*/
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): PrAvailability =
PrAvailability.fromWire(decoder.decodeString())
override fun serialize(encoder: Encoder, value: PrAvailability) =
encoder.encodeString(value.wire)
}
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
@Serializable
public data class PrCheckSummary(
val total: Int = 0,
val passing: Int = 0,
val failing: Int = 0,
val pending: Int = 0,
)
/**
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
* optional (present only when `availability == ok`); [availability] itself defaults to
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
* as plain text; no enum needed for display).
*/
@Serializable
public data class PrStatus(
@Serializable(with = PrAvailabilitySerializer::class)
val availability: PrAvailability = PrAvailability.ERROR,
val number: Int? = null,
val title: String? = null,
val url: String? = null,
val state: String? = null,
val isDraft: Boolean? = null,
val mergeable: String? = null,
val headRefName: String? = null,
val baseRefName: String? = null,
val checks: PrCheckSummary? = null,
)

View File

@@ -34,6 +34,12 @@ public data class ProjectInfo(
val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null,
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
val ahead: Int? = null,
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: Long? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)

View File

@@ -1,13 +1,24 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
@@ -87,6 +98,43 @@ public class ApiClient(
}
}
/**
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
* repo. Empty path rejected client-side before any I/O.
*/
public suspend fun projectPr(path: String): PrStatus {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectPr(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
* client-side before any I/O; `n` is clamped in the route builder.
*/
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectLog(path, n))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
@@ -132,6 +180,50 @@ public class ApiClient(
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
*/
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) {

View File

@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -14,6 +14,10 @@ internal object HttpStatus {
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400
const val SERVER_ERROR_MAX = 599
}
/** Header / content-type names (no magic strings inline). */

View File

@@ -57,6 +57,28 @@ internal object Endpoints {
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/pr",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
*/
fun projectLog(path: String, n: Int?): ApiRoute {
val query = StringBuilder("path=").append(percentEncode(path))
if (n != null) {
val clamped = n.coerceIn(1, GIT_LOG_MAX)
query.append("&n=").append(clamped)
}
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
}
// ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute =
@@ -92,6 +114,58 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
"/projects/worktree",
CreateWorktreeBody.serializer(),
CreateWorktreeBody(path, branch, base),
)
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.DELETE,
"/projects/worktree",
RemoveWorktreeBody.serializer(),
RemoveWorktreeBody(path, worktreePath, force),
)
/** `POST /projects/worktree/prune` — `{ path }`. */
fun pruneWorktrees(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
}
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
/**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
@@ -124,4 +198,22 @@ internal object Endpoints {
@Serializable
private data class FcmTokenBody(val token: String)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
@Serializable
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
@Serializable
private data class PruneBody(val path: String)
@Serializable
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
@Serializable
private data class CommitBody(val path: String, val message: String)
@Serializable
private data class PushBody(val path: String)
}

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
* subject-less commit still decodes (subject defaults to empty).
*/
class GitLogTest {
private fun decode(json: String): GitLogResult? =
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
@Test
fun `decodes commits and truncated`() {
val json = """
{ "truncated": true, "commits": [
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
] }
""".trimIndent()
val result = decode(json)!!
assertTrue(result.truncated)
assertEquals(2, result.commits.size)
assertEquals("abc123", result.commits[0].hash)
assertEquals(1710000000000L, result.commits[0].at)
assertEquals("first", result.commits[0].subject)
}
@Test
fun `drops a commit missing hash or at, keeping the rest`() {
val json = """
{ "truncated": false, "commits": [
{ "at": 1, "subject":"no hash" },
{ "hash":"keep", "at": 2, "subject":"kept" },
{ "hash":"noAt", "subject":"no at" }
] }
""".trimIndent()
val result = decode(json)!!
assertFalse(result.truncated)
assertEquals(1, result.commits.size)
assertEquals("keep", result.commits.single().hash)
}
@Test
fun `a subject-less commit still decodes with an empty subject`() {
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
assertEquals(1, result.commits.size)
assertEquals("", result.commits.single().subject)
assertFalse(result.truncated) // default
}
@Test
fun `a non-object body degrades to null`() {
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
* exercised (server can return `{ok:true, commit:""}`).
*/
class GitWriteTest {
@Test
fun `stage payload decodes staged and count`() {
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
assertEquals(StageResult(staged = true, count = 3), r)
}
@Test
fun `commit payload decodes the sha and tolerates an empty sha`() {
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
}
@Test
fun `push payload decodes branch and remote`() {
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
assertEquals("main", r.branch)
assertEquals("origin", r.remote)
}
@Test
fun `worktree create and remove and prune payloads decode`() {
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
assertEquals("/wt/x", create.path)
assertEquals("feat", create.branch)
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
assertEquals("/wt/x", remove.path)
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
assertEquals(listOf("a", "b"), prune.pruned)
}
@Test
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
}
@Test
fun `a git-ops failure body yields the safe error string`() {
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
}
@Test
fun `a worktree failure body (no ok field) still yields the error string`() {
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
}
@Test
fun `an empty or errorless failure body yields null`() {
assertNull(decodeGitError(ByteArray(0)))
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
assertNull(decodeGitError("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
* non-`ok` availability as an HTTP error.
*/
class PrStatusTest {
private fun decode(json: String): PrStatus? =
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
@Test
fun `decodes a full ok body with all fields and check counts`() {
val json = """
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
"state":"open", "isDraft":false, "mergeable":"mergeable",
"headRefName":"feat/wt", "baseRefName":"main",
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
""".trimIndent()
val pr = decode(json)!!
assertEquals(PrAvailability.OK, pr.availability)
assertEquals(42, pr.number)
assertEquals("Add worktrees", pr.title)
assertEquals("https://x/pull/42", pr.url)
assertEquals("open", pr.state)
assertEquals(false, pr.isDraft)
assertEquals("mergeable", pr.mergeable)
assertEquals("feat/wt", pr.headRefName)
assertEquals("main", pr.baseRefName)
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
}
@Test
fun `an unknown availability degrades to ERROR, never throwing`() {
val pr = decode("""{ "availability":"quantum-flux" }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
}
@Test
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
val pr = decode("""{ "number":7 }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
assertEquals(7, pr.number)
assertNull(pr.title)
assertNull(pr.checks)
}
@Test
fun `each known availability maps from its wire value`() {
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
}
@Test
fun `a non-object body degrades to null rather than throwing`() {
assertNull(decode("[]"))
assertNull(decode("not json"))
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
}
@Test
fun `unknown top-level keys are ignored`() {
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
assertEquals(PrAvailability.OK, pr.availability)
}
}

View File

@@ -0,0 +1,112 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
*/
class ApiClientGitTest {
private companion object {
const val BASE = "http://h:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
@Test
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
// Empty path is rejected before any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
}
@Test
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
}
// ── log ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/log?path=%2Fr",
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
)
assertEquals(1, client.projectLog("/r").commits.size)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
}
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
@Test
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
val outcome = client.gitCommit("/r", "msg")
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
// The write stamped an Origin.
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
}
@Test
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/projects/worktree",
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
)
val disabled = client.createWorktree("/r", "b", null)
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
transport.queueSuccess(
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
)
val dirty = client.removeWorktree("/r", "/r/x", false)
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
}
@Test
fun `429 yields RateLimited and never auto-retries`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
}
@Test
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
}
}

View File

@@ -0,0 +1,161 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
*/
class GitRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun last(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(r: HttpRequest) =
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
private fun assertReadOnly(r: HttpRequest) =
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
@Test
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
client.projectPr(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(url, r.url)
assertReadOnly(r)
}
@Test
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = null)
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
assertReadOnly(last())
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 999)
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 0)
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
}
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
@Test
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = "main")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
}
@Test
fun `createWorktree omits base when null`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = null)
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
}
@Test
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
}
@Test
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
client.pruneWorktrees("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree/prune", r.url)
assertGuarded(r)
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
}
@Test
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
assertEquals("$BASE/projects/git/stage", last().url)
assertGuarded(last())
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
client.gitCommit("/repo", "a message")
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
assertGuarded(last())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.gitPush("/repo")
assertEquals("$BASE/projects/git/push", last().url)
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
assertGuarded(last())
}
@Test
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.projectPr("/r"); client.projectLog("/r", null)
assertReadOnly(transport.recordedRequests[0])
assertReadOnly(transport.recordedRequests[1])
client.createWorktree("/r", "b", null)
client.removeWorktree("/r", "/r/x", false)
client.pruneWorktrees("/r")
client.gitStage("/r", listOf("f"), true)
client.gitCommit("/r", "m")
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
}

View File

@@ -22,6 +22,7 @@ import wang.yaojia.webterm.screens.ClientCertScreen
import wang.yaojia.webterm.screens.DiffScreen
import wang.yaojia.webterm.screens.PairingScreen
import wang.yaojia.webterm.screens.ProjectDetailScreen
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.viewmodels.DiffViewModel
@@ -106,6 +107,7 @@ public fun ProjectDetailPane(
path = path,
onBack = { navController.popBackStack() },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
modifier = modifier,
)
}
@@ -118,9 +120,10 @@ public fun ProjectDetailContent(
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) {
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier, onViewDiff = onViewDiff)
}
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
@@ -151,7 +154,12 @@ public fun DiffPane(
return
}
val viewModel = remember(resolved, path) {
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
DiffViewModel(
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
path = path,
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
)
}
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
}

View File

@@ -83,6 +83,7 @@ public fun ProjectsHome(
path = path,
onBack = { selectedPath = null },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
)
} else {
DetailPlaceholder("选择一个项目查看详情。")

View File

@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -44,15 +48,17 @@ import wang.yaojia.webterm.viewmodels.DiffPhase
import wang.yaojia.webterm.viewmodels.DiffRow
import wang.yaojia.webterm.viewmodels.DiffUiState
import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.DiffWriteBanner
/**
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
* # DiffScreen (A24 + W5) — the git-diff viewer with base-compare + git-write.
*
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
* toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
* (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
* **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
* rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
* device-QA (plan §7).
*/
@Composable
public fun DiffScreen(
@@ -61,29 +67,37 @@ public fun DiffScreen(
modifier: Modifier = Modifier,
onRefresh: () -> Unit = {},
onBack: (() -> Unit)? = null,
onSetBase: (String?) -> Unit = {},
onToggleStage: (String, Boolean) -> Unit = { _, _ -> },
onCommit: (String) -> Unit = {},
onPush: () -> Unit = {},
onDismissBanner: () -> Unit = {},
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
if (state.truncated) {
DiffNotice("Diff truncated — too large to display fully.")
}
DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (state.phase) {
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
DiffPhase.EMPTY -> CenteredMessage("No changes")
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
DiffPhase.LOADED -> DiffList(rows = state.rows)
DiffPhase.LOADED -> DiffList(rows = state.rows, writeEnabled = state.writeEnabled, staged = state.staged, onToggleStage = onToggleStage)
}
}
if (state.writeEnabled) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
CommitBar(writing = state.writing, onCommit = onCommit, onPush = onPush)
}
}
}
}
/**
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
* toggle/refresh/base/git-write callbacks. The nav layer supplies the already-constructed presenter.
*/
@Composable
public fun DiffScreen(
@@ -92,7 +106,6 @@ public fun DiffScreen(
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
LaunchedEffect(viewModel) { viewModel.bind(this) }
DiffScreen(
state = state,
@@ -100,50 +113,60 @@ public fun DiffScreen(
modifier = modifier,
onRefresh = viewModel::refresh,
onBack = onBack,
onSetBase = viewModel::setBase,
onToggleStage = viewModel::toggleStage,
onCommit = viewModel::commit,
onPush = viewModel::push,
onDismissBanner = viewModel::clearWriteBanner,
)
}
@Composable
private fun DiffHeader(
staged: Boolean,
state: DiffUiState,
onSelectStaged: (Boolean) -> Unit,
onBack: (() -> Unit)?,
onSetBase: (String?) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
Spacer(modifier = Modifier.width(Spacing.sm8))
if (state.base == null) {
// Working/Staged toggle is suppressed in base mode (server ignores staged then).
FilterChip(selected = !state.staged, onClick = { onSelectStaged(false) }, label = { Text("Working") })
FilterChip(selected = state.staged, onClick = { onSelectStaged(true) }, label = { Text("Staged") })
} else {
Text(text = "vs ${state.base}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(top = Spacing.xs4)) {
OutlinedTextField(
value = baseInput,
onValueChange = { baseInput = it },
label = { Text("对比基点 (base rev)") },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { onSetBase(baseInput.takeIf { it.isNotBlank() }) }) { Text("对比") }
if (state.base != null) OutlinedButton(onClick = { baseInput = ""; onSetBase(null) }) { Text("清除") }
}
Text(
text = "Diff",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.width(Spacing.sm8))
FilterChip(
selected = !staged,
onClick = { onSelectStaged(false) },
label = { Text("Working") },
)
FilterChip(
selected = staged,
onClick = { onSelectStaged(true) },
label = { Text("Staged") },
)
}
}
@Composable
private fun DiffList(rows: List<DiffRow>) {
private fun DiffList(
rows: List<DiffRow>,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { row ->
when (row) {
is DiffFileHeaderRow -> FileHeader(row)
is DiffFileHeaderRow -> FileHeader(row, writeEnabled = writeEnabled, staged = staged, onToggleStage = onToggleStage)
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
}
@Composable
private fun FileHeader(row: DiffFileHeaderRow) {
private fun FileHeader(
row: DiffFileHeaderRow,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
)
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
if (writeEnabled) {
// In staged view we offer Unstage; in working view we offer Stage.
TextButton(onClick = { onToggleStage(row.stagePath, !staged) }) { Text(if (staged) "取消暂存" else "暂存") }
}
}
}
@Composable
private fun CommitBar(writing: Boolean, onCommit: (String) -> Unit, onPush: () -> Unit) {
var message by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text("提交信息") },
singleLine = true,
enabled = !writing,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(enabled = !writing, onClick = { onCommit(message); message = "" }) { Text("提交") }
OutlinedButton(enabled = !writing, onClick = onPush) { Text("推送") }
}
}
}
@Composable
private fun WriteBanner(banner: DiffWriteBanner, onDismiss: () -> Unit) {
val color = if (banner.isError) WebTermColors.statusStuck else WebTermColors.statusWorking
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = banner.message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("×") }
}
}
@@ -185,9 +247,7 @@ private fun DiffText(text: String, color: Color) {
softWrap = false,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = 1.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
)
}
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
)
}
@Composable
private fun CenteredMessage(message: String) {
CenteredContent {
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
@Composable
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
@Composable
private fun DiffScreenPreview() {
val rows = listOf<DiffRow>(
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
)
WebTermTheme {
DiffScreen(
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true, canWrite = true),
onSelectStaged = {},
)
}

View File

@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.WorktreeInfo
@@ -36,19 +49,19 @@ import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsCopy
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
import java.net.URI
/**
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
* # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
* plus the W5 additions: a **PR + CI chip** (tappable only when the PR url parses as https), a
* **recent-commits** section, and guarded **worktree create / remove / prune** actions.
*
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
*
* @param onBack pop back to the projects grid.
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
* Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
* as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
* link ONLY when its url is a valid https URL (scheme-validated before it is made clickable). The
* worktree actions drive [ProjectDetailViewModel.worktree]; a remove force-confirms in a dialog and a
* main worktree is never removable.
*/
@Composable
public fun ProjectDetailScreen(
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) {
val phase by viewModel.phase.collectAsStateWithLifecycle()
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() }
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
is ProjectDetailViewModel.Phase.Failed ->
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
is ProjectDetailViewModel.Phase.Loaded ->
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
DetailBody(
detail = current.detail,
prChip = prChip,
recent = recent,
worktree = viewModel.worktree,
onOpenClaude = onOpenClaude,
onViewDiff = onViewDiff,
)
}
}
}
@@ -90,7 +113,14 @@ private fun DetailHeaderBar(onBack: () -> Unit) {
}
@Composable
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
private fun DetailBody(
detail: ProjectDetail,
prChip: ProjectDetailViewModel.PrChip,
recent: ProjectDetailViewModel.RecentCommits,
worktree: WorktreeViewModel?,
onOpenClaude: (String) -> Unit,
onViewDiff: (String) -> Unit = {},
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
}
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) {
EmptyLine("不是 git 仓库。")
} else if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
PrChipRow(prChip)
if (detail.isGit && worktree != null) {
WorktreeSection(detail = detail, worktree = worktree)
} else {
for (worktree in detail.worktrees) WorktreeRow(worktree)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) EmptyLine("不是 git 仓库。")
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
}
val running = detail.sessions.filter { !it.exited }
SectionTitle("运行中的会话(${running.size}")
if (running.isEmpty()) {
EmptyLine("没有运行中的会话 —— 在下方开一个。")
} else {
for (session in running) SessionRow(session)
}
if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
else for (session in running) SessionRow(session)
RecentCommitsSection(recent)
SectionTitle("CLAUDE.md")
val claudeMd = detail.claudeMd
if (detail.hasClaudeMd && claudeMd != null) {
ClaudeMdBlock(claudeMd)
} else {
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
}
if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") }
}
}
}
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
@Composable
private fun PrChipRow(prChip: ProjectDetailViewModel.PrChip) {
when (prChip) {
ProjectDetailViewModel.PrChip.Hidden -> Unit
ProjectDetailViewModel.PrChip.Loading -> EmptyLine("正在读取 PR 状态…")
ProjectDetailViewModel.PrChip.Unavailable -> EmptyLine("PR 状态不可用。")
is ProjectDetailViewModel.PrChip.Loaded -> PrChipContent(prChip.status)
}
}
@Composable
private fun WorktreeRow(worktree: WorktreeInfo) {
private fun PrChipContent(pr: PrStatus) {
val uriHandler = LocalUriHandler.current
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
val label = prChipLabel(pr)
val color = prChipColor(pr)
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
AssistChip(
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = AssistChipDefaults.assistChipColors(labelColor = color),
)
} else {
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
Text(text = label, style = WebTermType.metaMono, color = color)
}
}
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
PrAvailability.OK -> {
val num = pr.number?.let { "#$it " } ?: ""
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
"PR $num${pr.title ?: ""}$checks".trim()
}
PrAvailability.NO_PR -> "当前分支没有 PR"
PrAvailability.NOT_INSTALLED -> "未安装 gh无法读取 PR"
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
PrAvailability.DISABLED -> "PR 集成已禁用"
PrAvailability.ERROR -> "PR 状态读取失败"
}
@Composable
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
else -> WebTermColors.statusWorking
}
private fun isHttpsUrl(url: String): Boolean =
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
@Composable
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
val scope = rememberCoroutineScope()
val phase by worktree.phase.collectAsStateWithLifecycle()
var branch by remember { mutableStateOf("") }
var base by remember { mutableStateOf("") }
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
}
// New-worktree inline form.
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = branch,
onValueChange = { branch = it },
label = { Text("新工作树分支名") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = base,
onValueChange = { base = it },
label = { Text("基点(可选)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.create(branch, base) } },
) { Text("新建工作树") }
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.prune() } },
) { Text("清理") }
}
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
}
}
val target = removeTarget
if (target != null) {
RemoveWorktreeDialog(
worktree = target,
onConfirm = { force ->
removeTarget = null
scope.launch { worktree.remove(target, force) }
},
onDismiss = { removeTarget = null },
)
}
}
@Composable
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
when (phase) {
WorktreeViewModel.Phase.Idle -> Unit
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
}
}
@Composable
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
@Composable
private fun RemoveWorktreeDialog(
worktree: WorktreeInfo,
onConfirm: (force: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var force by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("删除工作树") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = force, onCheckedChange = { force = it })
Text("强制删除(丢弃未提交改动)")
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
@Composable
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
when (recent) {
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
ProjectDetailViewModel.RecentCommits.Loading -> {
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
}
ProjectDetailViewModel.RecentCommits.Unavailable -> {
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
}
is ProjectDetailViewModel.RecentCommits.Loaded -> {
SectionTitle("最近提交")
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
else for (commit in recent.result.commits) CommitRow(commit)
}
}
}
@Composable
private fun CommitRow(commit: CommitLogEntry) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
Text(
text = commit.hash.take(7),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.subject,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
@Composable
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked")
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
}
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@@ -178,7 +408,6 @@ private fun SessionRow(session: ProjectSessionRef) {
@Composable
private fun ClaudeMdBlock(text: String) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
claudeMd = "# CLAUDE.md\n\nProject instructions…",
)
WebTermTheme {
DetailBody(detail = detail, onOpenClaude = {})
DetailBody(
detail = detail,
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
recent = ProjectDetailViewModel.RecentCommits.Loaded(
wang.yaojia.webterm.api.models.GitLogResult(
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
truncated = false,
),
),
worktree = null,
onOpenClaude = {},
)
}
}

View File

@@ -222,13 +222,21 @@ private fun ProjectCard(
}
}
project.branch?.let { branch ->
Text(
text = branch,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(
text = branch,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// W3 sync chip: commits ahead/behind upstream (best-effort; only shown when non-zero).
val ahead = project.ahead ?: 0
val behind = project.behind ?: 0
if (ahead > 0) Text(text = "$ahead", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
if (behind > 0) Text(text = "$behind", style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
}
}
val running = project.sessions.count { !it.exited }
if (running > 0) {

View File

@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -47,14 +52,17 @@ import java.net.URI
public class DiffViewModel(
private val fetcher: DiffFetcher,
private val path: String,
/** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
private val writer: GitWriteGateway? = null,
) {
private val _uiState = MutableStateFlow(DiffUiState())
private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))
/** The single snapshot `DiffScreen` renders from. */
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var job: Job? = null
private var writeJob: Job? = null
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
public fun bind(scope: CoroutineScope) {
@@ -62,27 +70,46 @@ public class DiffViewModel(
reload()
}
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
* No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
public fun selectStaged(staged: Boolean) {
if (_uiState.value.base != null) return
if (_uiState.value.staged == staged) return
_uiState.value = _uiState.value.copy(staged = staged)
reload()
}
/**
* Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
* git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
*/
public fun setBase(rev: String?) {
val next = rev?.trim()?.takeIf { it.isNotEmpty() }
if (_uiState.value.base == next) return
_uiState.value = _uiState.value.copy(base = next, writeBanner = null)
reload()
}
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
public fun refresh() {
reload()
}
/** Dismiss the git-write result banner. */
public fun clearWriteBanner() {
_uiState.value = _uiState.value.copy(writeBanner = null)
}
private fun reload() {
val scope = scope ?: return
job?.cancel()
val staged = _uiState.value.staged
val base = _uiState.value.base
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
job = scope.launch {
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
val outcome = try {
Result.success(fetcher.fetch(path, staged))
Result.success(fetcher.fetch(path, staged, base))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
@@ -101,6 +128,102 @@ public class DiffViewModel(
)
}
}
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
public fun toggleStage(newPath: String, staged: Boolean) {
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
}
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
public fun commit(message: String) {
if (message.isBlank()) {
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
return
}
runWrite { writer!!.gitCommit(path, message) }
}
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
public fun push() {
runWrite { writer!!.gitPush(path) }
}
/**
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
* [writeJob] so a rapid double-tap never races.
*/
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
val scope = scope ?: return
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
writeJob?.cancel()
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
writeJob = scope.launch {
val outcome = try {
Result.success(op())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
val banner = outcome.fold(
onSuccess = { bannerFor(it) },
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
)
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
if (!banner.isError) reload() // refresh the diff after a successful write
}
}
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
}
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
// their own message — so message is already the display copy.
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
}
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
public interface GitWriteGateway {
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
}
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
api.gitStage(path, files, stage)
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
api.gitCommit(path, message)
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
}
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
public data class DiffWriteBanner(val message: String, val isError: Boolean)
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
public object DiffCopy {
public const val COMMIT_EMPTY: String = "请填写提交信息。"
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
public fun okBanner(payload: Any?): String = when (payload) {
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
is PushResult -> "已推送 ${payload.branch ?: "分支"}${payload.remote ?: "远端"}"
else -> "操作完成"
}
}
/** The load phase the screen renders (loading spinner / empty / error / list). */
@@ -108,14 +231,25 @@ public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
/** The immutable snapshot the diff screen renders. */
public data class DiffUiState(
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
val staged: Boolean = false,
/** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
val base: String? = null,
val phase: DiffPhase = DiffPhase.IDLE,
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
val rows: List<DiffRow> = emptyList(),
/** Server capped the diff (too large) — the screen shows a truncation notice. */
val truncated: Boolean = false,
)
/** True once a git-write is in flight — the screen disables the write controls. */
val writing: Boolean = false,
/** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
val writeBanner: DiffWriteBanner? = null,
/** Whether git-write controls are offered at all (a writer gateway was supplied). */
val canWrite: Boolean = false,
) {
/** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
val writeEnabled: Boolean get() = canWrite && base == null
}
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
public val id: Long
}
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
/** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
* file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
public data class DiffFileHeaderRow(
override val id: Long,
val path: String,
val stagePath: String,
val status: String,
val added: Int,
val removed: Int,
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
val rows = ArrayList<DiffRow>()
var id = 0L
for (file in result.files) {
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
if (file.binary) {
rows.add(DiffBinaryRow(id++))
continue
@@ -212,7 +348,13 @@ public data class DiffFile(
val hunks: List<DiffHunk>,
)
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
public data class DiffResult(
val files: List<DiffFile>,
val staged: Boolean,
val truncated: Boolean,
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
val base: String? = null,
)
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
private val DiffJson: Json = Json {
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
.getOrNull() as? JsonObject
?: return DiffResult(emptyList(), staged = false, truncated = false)
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
return DiffResult(
files = files,
staged = root.bool("staged", false),
truncated = root.bool("truncated", false),
base = root.str("base"),
)
}
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
@@ -278,8 +425,12 @@ private fun JsonObject.bool(key: String, default: Boolean): Boolean =
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
public interface DiffFetcher {
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
public suspend fun fetch(path: String, staged: Boolean): DiffResult
/**
* @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
* in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
* @throws DiffUnavailable on a non-200 status; transport errors propagate.
*/
public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
}
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
@@ -293,8 +444,8 @@ public class HttpDiffFetcher(
private val endpoint: HostEndpoint,
private val http: HttpTransport,
) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body)
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
private const val HTTP_BAD_REQUEST = 400
/**
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
* Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
* base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
* ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
* `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
* the exact query value.
*/
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null
if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val stagedValue = if (staged) "1" else "0"
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
val trimmedBase = base?.trim()
return if (!trimmedBase.isNullOrEmpty()) {
"$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
} else {
"$prefix&staged=${if (staged) "1" else "0"}"
}
}
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */

View File

@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.routes.ApiClientError
/**
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
* # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
* state machine, PLUS two failure-ISOLATED side fetches: the PR + CI chip (`GET /projects/pr`) and the
* recent-commits list (`GET /projects/log`). A failure of either side fetch NEVER fails the detail load
* (each has its own StateFlow) — the chip/list simply render an unavailable state.
*
* The [fetch] closure is injected production wraps [ProjectsGateway.projectDetail] (the builder's
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
* fake. This VM only reduces the three user-visible outcomes:
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
* The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
* [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
* [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
* success (via [load]).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* [load] in a lifecycle scope; the retry action re-calls it.
*/
public class ProjectDetailViewModel(
public val path: String,
private val fetch: suspend () -> ProjectDetail,
private val fetchPr: (suspend () -> PrStatus)? = null,
private val fetchLog: (suspend () -> GitLogResult)? = null,
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
public val worktree: WorktreeViewModel? = null,
) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
public data class Failed(val failure: Failure) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
/** The PR + CI chip's own state (isolated from the detail load). */
public sealed interface PrChip {
public data object Hidden : PrChip
public data object Loading : PrChip
public data class Loaded(val status: PrStatus) : PrChip
public data object Unavailable : PrChip
}
/** The single snapshot `ProjectDetailScreen` renders from. */
/** The recent-commits section's own state (isolated from the detail load). */
public sealed interface RecentCommits {
public data object Hidden : RecentCommits
public data object Loading : RecentCommits
public data class Loaded(val result: GitLogResult) : RecentCommits
public data object Unavailable : RecentCommits
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
/** The main detail snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
/** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
/** The recent-commits snapshot. */
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
/**
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
* the detail load nor each other).
*/
public suspend fun load() {
_phase.value = Phase.Loading
_phase.value = try {
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
// Transport/decode etc. — a retryable catch-all.
Phase.Failed(Failure.UNAVAILABLE)
}
if (_phase.value is Phase.Loaded) {
loadPr()
loadRecentCommits()
}
}
private suspend fun loadPr() {
val fetcher = fetchPr ?: return
_prChip.value = PrChip.Loading
_prChip.value = try {
PrChip.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
PrChip.Unavailable // isolated: a PR fetch failure never touches the detail phase
}
}
private suspend fun loadRecentCommits() {
val fetcher = fetchLog ?: return
_recentCommits.value = RecentCommits.Loading
_recentCommits.value = try {
RecentCommits.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
RecentCommits.Unavailable
}
}
private fun failureFor(error: ApiClientError): Failure = when (error) {
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
}
public companion object {
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
/**
* Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
* detail + PR + log fetches and a [WorktreeViewModel] whose successes re-fetch this detail.
*/
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel {
var self: ProjectDetailViewModel? = null
val worktree = WorktreeViewModel(gateway, path, onChanged = { self?.load() })
val vm = ProjectDetailViewModel(
path = path,
fetch = { gateway.projectDetail(path) },
fetchPr = { gateway.projectPr(path) },
fetchLog = { gateway.projectLog(path, null) },
worktree = worktree,
)
self = vm
return vm
}
}
}

View File

@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
@@ -396,12 +402,26 @@ public data class ProjectsUiState(
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
/**
* Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
* The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
* [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
* :api-client Origin-stamping point (plan §Security).
*/
public interface ProjectsGateway {
public suspend fun projects(): List<ProjectInfo>
public suspend fun prefs(): UiPrefs
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
public suspend fun projectDetail(path: String): ProjectDetail
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
public suspend fun projectPr(path: String): PrStatus
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
}
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
@@ -410,6 +430,15 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
override suspend fun prefs(): UiPrefs = api.prefs()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
api.createWorktree(path, branch, base)
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
api.removeWorktree(path, worktreePath, force)
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
}
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */

View File

@@ -0,0 +1,149 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
*
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
*
* ### Defense in depth (UX, not the security boundary)
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
* surfaced INERT (plain text; never linkified).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
*/
public class WorktreeViewModel(
private val gateway: ProjectsGateway,
private val repoPath: String,
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
private val onChanged: suspend () -> Unit = {},
) {
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
public sealed interface Phase {
public data object Idle : Phase
public data object Working : Phase
public data class Done(val message: String) : Phase
public data class Failed(val message: String) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
/** The single snapshot the worktree sheet/dialog renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
public suspend fun create(branch: String, base: String? = null) {
val trimmed = branch.trim()
if (!isValidBranchName(trimmed)) {
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
}
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
if (worktree.isMain) {
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
}
/** Reclaim stale worktree admin dirs (idempotent). */
public suspend fun prune() {
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
}
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
public fun reset() {
_phase.value = Phase.Idle
}
/**
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
*/
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
val outcome = try {
op()
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
}
return when (outcome) {
is GitWriteOutcome.Ok -> {
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
}
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
}
}
public companion object {
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
private const val MAX_BRANCH_LEN = 250
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
/**
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
* `.`, `@{`, and any forbidden char.
*/
public fun isValidBranchName(branch: String): Boolean {
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
if (branch.startsWith("-")) return false
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
if (branch.contains("..")) return false
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
if (branch.contains("@{")) return false
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
return true
}
}
}
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
public object WorktreeCopy {
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
public const val REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun failed(detail: String): String = "工作树操作失败:$detail"
public fun okMessage(payload: Any?): String = when (payload) {
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
is RemoveWorktreeResult -> "已删除工作树"
is PruneWorktreesResult ->
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
else -> "操作完成"
}
}

View File

@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
/**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
@@ -101,8 +107,10 @@ class DiffViewModelTest {
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val bases = mutableListOf<String?>() // records the base arg of each fetch
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
calls += staged
bases += base
error?.let { throw it }
return result!!
}
@@ -154,4 +162,133 @@ class DiffViewModelTest {
assertTrue(vm.uiState.value.staged)
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
}
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
@Test
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
diffUrl("http://h:3000", "/repo", staged = true, base = null),
)
// base mode: no staged param, base percent-encoded.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
)
// a blank base is treated as working mode.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
)
}
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
@Test
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val vm = DiffViewModel(fetcher, "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
assertEquals("main", vm.uiState.value.base)
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
vm.selectStaged(true); advanceUntilIdle()
assertFalse(vm.uiState.value.staged)
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
// Leaving base mode returns to the working/staged view.
vm.setBase(null); advanceUntilIdle()
assertNull(vm.uiState.value.base)
assertEquals(listOf(null, "main", null), fetcher.bases)
}
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
private class FakeWriter(
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
) : GitWriteGateway {
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
var commitCalls = 0; var pushCalls = 0
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
stageCalls += Triple(path, files, stage); return this.stage
}
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
}
@Test
fun `toggleStage posts the file and refreshes the diff`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
assertEquals(false, vm.uiState.value.writeBanner?.isError)
}
@Test
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
assertEquals(true, vm.uiState.value.writeBanner?.isError)
vm.commit("real message"); advanceUntilIdle()
assertEquals(1, writer.commitCalls)
assertEquals(false, vm.uiState.value.writeBanner?.isError)
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
}
@Test
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.push(); advanceUntilIdle()
assertEquals(1, writer.pushCalls)
assertEquals(true, vm.uiState.value.writeBanner?.isError)
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
}
@Test
fun `git-write is disabled in base mode`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
assertFalse(vm.uiState.value.writeEnabled)
}
@Test
fun `writeEnabled is false without a writer and true with one in working mode`() {
assertFalse(DiffUiState(canWrite = false).writeEnabled)
assertTrue(DiffUiState(canWrite = true).writeEnabled)
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.viewmodels
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
/**
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
* (projects/prefs) are unused here and throw if called.
*/
class FakeWorktreeGateway(
private val detail: ProjectDetail? = null,
private val prResult: PrStatus? = null,
private val prThrows: Boolean = false,
private val logResult: GitLogResult? = null,
private val logThrows: Boolean = false,
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
) : ProjectsGateway {
val createCalls = mutableListOf<Triple<String, String, String?>>()
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
val pruneCalls = mutableListOf<String>()
var detailCalls = 0
private set
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
override suspend fun projectDetail(path: String): ProjectDetail {
detailCalls++
return detail ?: throw NotImplementedError("no detail configured")
}
override suspend fun projectPr(path: String): PrStatus {
if (prThrows) throw RuntimeException("pr unavailable")
return prResult ?: throw NotImplementedError("no pr configured")
}
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
if (logThrows) throw RuntimeException("log unavailable")
return logResult ?: throw NotImplementedError("no log configured")
}
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
createCalls += Triple(path, branch, base)
return createOutcome
}
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
removeCalls += Triple(path, worktreePath, force)
return removeOutcome
}
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
pruneCalls += path
return pruneOutcome
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
/**
* W5 ProjectDetailViewModel side fetches (JVM). The PR chip and recent-commits list are failure-
* ISOLATED: a failure of either NEVER fails the detail load nor the other; a non-`ok` availability
* renders a degraded (but Loaded) chip; the commit list decodes into its own state.
*/
class ProjectDetailPrLogTest {
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "main")
@Test
fun `detail plus PR plus log all load`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.OK, number = 7, title = "A PR"),
logResult = GitLogResult(commits = listOf(CommitLogEntry("h", 1, "s")), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.OK, chip.status.availability)
val commits = vm.recentCommits.value as ProjectDetailViewModel.RecentCommits.Loaded
assertEquals(1, commits.result.commits.size)
}
@Test
fun `a PR fetch failure does not fail the detail load nor the log`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prThrows = true,
logResult = GitLogResult(commits = emptyList(), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "detail must still load")
assertEquals(ProjectDetailViewModel.PrChip.Unavailable, vm.prChip.value)
assertTrue(vm.recentCommits.value is ProjectDetailViewModel.RecentCommits.Loaded, "log stays isolated")
}
@Test
fun `a log fetch failure isolates to the recent-commits state only`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.NO_PR),
logThrows = true,
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
assertEquals(ProjectDetailViewModel.RecentCommits.Unavailable, vm.recentCommits.value)
// A non-ok availability is still a Loaded chip (degraded copy is a render concern).
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.NO_PR, chip.status.availability)
}
@Test
fun `the wired worktree VM shares the repo path and refreshes the detail on a successful create`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.DISABLED),
logResult = GitLogResult(),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
val detailCallsAfterLoad = gateway.detailCalls
vm.worktree!!.create("feat/x")
assertTrue(gateway.detailCalls > detailCallsAfterLoad, "create success re-fetches the detail")
assertEquals("/repo", gateway.createCalls.single().first)
}
}

View File

@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
}
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
override suspend fun projectPr(path: String) = throw NotImplementedError()
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
}
private fun proj(

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* W5 WorktreeViewModel (JVM). The guarded worktree write phase machine: client-side branch validation
* (no I/O on a bad name), main-worktree removal blocked client-side, the force flag threaded, and the
* server's SAFE error strings (disabled 403 / 429) surfaced inertly. On success it re-fetches the detail.
*/
class WorktreeViewModelTest {
@Test
fun `an invalid branch name fails with no network I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("bad branch~name") // whitespace + '~' are forbidden
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Failed)
assertEquals(WorktreeCopy.INVALID_BRANCH, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.createCalls.size, "an invalid branch must never hit the network")
}
@Test
fun `a leading dash and dotdot and trailing dot are all rejected client-side`() {
assertTrue(WorktreeViewModel.isValidBranchName("feat/ok-name"))
assertTrue(WorktreeViewModel.isValidBranchName("release/1.2.x"))
listOf("-flag", "a..b", "ends.", "has space", "a~b", "a:b", "@{now}", "", "//x", "/lead", "trail/").forEach {
assertTrue(!WorktreeViewModel.isValidBranchName(it), "should reject '$it'")
}
}
@Test
fun `create success settles Done and re-fetches the detail`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Ok(CreateWorktreeResult(path = "/repo-worktrees/feat", branch = "feat/x")),
)
var refreshes = 0
val vm = WorktreeViewModel(gateway, "/repo", onChanged = { refreshes++ })
vm.create("feat/x", base = "main")
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(1, refreshes, "a successful create must re-fetch the detail")
assertEquals(Triple("/repo", "feat/x", "main"), gateway.createCalls.single())
}
@Test
fun `removing a main worktree is blocked client-side with no I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/repo", branch = "main", isMain = true), force = false)
assertEquals(WorktreeCopy.CANNOT_REMOVE_MAIN, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.removeCalls.size)
}
@Test
fun `remove threads the force flag`() = runTest {
val gateway = FakeWorktreeGateway(removeOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult(path = "/wt/x")))
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/wt/x", branch = "feat", isMain = false), force = true)
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(Triple("/repo", "/wt/x", true), gateway.removeCalls.single())
}
@Test
fun `a 403 disabled rejection surfaces the safe server message inertly`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Rejected(403, "Worktree creation is disabled."),
)
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("feat/x")
assertEquals("Worktree creation is disabled.", (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `a 429 rate-limit surfaces the rate-limited copy`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.RateLimited)
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
assertEquals(WorktreeCopy.RATE_LIMITED, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `prune with nothing to reclaim reports an empty result`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.Ok(PruneWorktreesResult(pruned = emptyList())))
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
val done = vm.phase.value as WorktreeViewModel.Phase.Done
assertTrue(done.message.contains("没有"))
}
}

View File

@@ -24,7 +24,23 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🖥 SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11,当前活跃)
### 🗺 ROADMAP 落地 — Wave 1-4 八个功能(2026-07-12,当前活跃;多 agent 并行计划 + 逐个 builder 实施)
- **产物**: [docs/ROADMAP.md](./ROADMAP.md)(分层 backlog)+ [docs/plans/](./plans/)(8 份可直接照写的实施计划,并行生成)。全在 `develop`
- **编排**: 主线洞察"产品把一切都**采集**了却**没拿去行动**",两个原语(PTY-inject、审批预览)解锁一片。流程 = **并行生成 8 份计划**(1 个 workflow / 8 agent)→ **逐个功能派 builder 在主工作树实现**(一次一个,避免踩 server.ts 等共享文件)→ **orchestrator 独立复验**(typecheck 两 config + full suite + build:web + 安全审)→ 提交 → 下一个。
- **[x] 八个全绿(独立复验,非仅采信 builder)**,每个单独 commit:
1. **W1 可点链接/文件路径** `debf47d``public/link-paths.ts` 纯 matcher + xterm link provider;URL scheme 白名单 + noopener;附加 `openFileInEditor`(`--goto file:line`,现有路由只能开目录)。
2. **W1 审批预览** `e062065` — 手机上 Approve 前先看到 Bash 命令/Edit diff。`src/http/approval-preview.ts` 有界 sanitize(40 行/200 字/4KB),走 gate 同款晚加入者重发;渲染只走 textContent/renderDiffFile。
3. **W2 PTY-inject + idle 队列** `3076843` — 地基原语。`POST /live-sessions/:id/queue`(Origin+限流+SESSION_ID_RE),idle 时 drain 一条(去抖 timer + pop-one + settle 复检 三重防重复),注入复用 writeInput 字节原样进 PTY。
4. **W3 diff-vs-base** `b119c31``?base=<rev>` 审整条分支。三层防选项注入:isPlausibleRev → `rev-parse --verify --end-of-options` → 只有解析出的 sha 进 `git diff <sha>... --`
5. **W3 PR/CI chip** `7551f8a``src/http/gh.ts` 单次 `gh pr view --json`;缺 gh/未登录/无 PR 全降级不抛;PR title 走 textContent。
6. **W3 quick wins** `1dd12b0` — 项目卡 ahead/behind + 最近提交时间;成本预算告警(`COST_BUDGET_USD` 单次 latch + push);`/digest` 重连摘要;`/projects/log` 最近提交。
7. **W4 worktree 删除/prune** `552f35c` — 破坏性,护栏:必须在 `git worktree list`(realpath 匹配)、拒主 worktree、容器内、脏树要 force、locked 拒、错误归类、`git worktree remove` 不用 rm -rf。
8. **W4 stage/commit/push** `19f241d` — 手机审完直接落地。MVP 只 stage/commit/push 当前分支(砍 discard/checkout);push 的 remote+branch 从 repo 读、绝不 `--force`/`+refspec`;三路由 Origin+GIT_OPS_ENABLED+限流;路径 realpath 容器内 + `--` 后作 argv;错误归类不泄露。
- **已知测试抖动(非回归)**: 两个真-PTY/tmux 集成测试(`ring buffer` 重放、`H1 tmux`)在沙箱满负载下撞默认 5s / 自身 20s 超时;**单独跑或 `--test-timeout=30000` 全绿**(full suite @30s = **2005/2006**,唯一 red 是 H1 tmux 撞自身上限)。逻辑无回归。
- **未 push**: 全部本地 `develop`,领先 origin/develop 一批。
### 🖥️ SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11)
- **需求**: web/Mac 大屏、开多个 tab 时,把 `#term` 大窗切成 1×2 / 2×2 宫格,多个 **live 可交互**终端同屏,方便"vibe coding"时盯多个 Claude session。手机不做(<1024px 强制 single)。
- **分支**: `feat/split-grid-view`( `feat/tunnel-automation`)。**用户决策(AskUserQuestion)**:全部阶段(v1v2v3)用多 agent + loop 完成;审批用**每格内联 ✓/✗**;成员=**前 N tab(拖拽换序控制)**;布局=**single + 1×2 + 2×2**。
- **编排**: orchestrator 亲写互锁的 5 文件(并行 builder 会互相踩),每阶段后**并行对抗式 review workflow**(4 lens 逐条 verify)→ confirmed 复验绿 commit loop 下一阶段

160
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,160 @@
# Roadmap
> **Status (2026-07-13):** ALL of Wave 1-5 (11 items) implemented + committed on `develop` — see docs/PROGRESS_LOG.md for per-feature commits. Remaining: only the deferred backlog table (smaller, several now cheap on the shipped PTY-inject + approval-preview primitives).
Prioritized backlog of features to build next, derived from a grounded analysis of
the codebase + the existing planning docs (5 exploration lenses → 24 candidates →
this synthesis). Each item keeps **what it touches** (real files/subsystems) and a
rough **effort** so it's actionable, not aspirational.
**The throughline:** the product already *captures* everything — Claude Code hooks,
statusLine telemetry, the activity timeline, git diff, live-sessions — but under-*acts*
on it. The highest-leverage work turns passive capture into a **trustworthy remote
review-and-drive surface**, without touching the byte-shuttle. Two small primitives
unlock a whole family of features: a **server-side PTY-inject** call and a **preview on
the approval bar**.
Effort key: **S** ≈ 12 days · **M** ≈ 34 days · **L** ≈ 12 weeks. Order = suggested
build sequence (dependencies noted).
---
## Recently shipped (context)
- **Split-grid watch board (v0.8, desktop)** — 1×2/1×3/2×2/2×3 layouts, click-to-focus,
per-quadrant inline approve / maximize / read-only monitor, drag-to-quadrant,
resizable splitters, saved presets. (`public/grid-layout.ts`, `grid-presets.ts`,
`cell-monitor.ts`, `tabs.ts`.)
- **Fixed:** browser worktree-create was 400ing (frontend sent `repoPath`, server reads
`path`); now aligned + regression-tested (`public/projects.ts`, `test/worktree-form.test.ts`).
---
## Wave 1 — ship first (high-trust / high-delight, independent)
- [x] **Approval preview — see-what-you-approve**_(strongest)_
Show the pending `Bash` command or `Edit`/`Write` diff *above* Approve/Reject, so remote
one-tap approval stops being blind (today you could tap Approve on `rm -rf build`).
*The core walk-away trust gap.*
**Touches:** `src/http/hook.ts` (`tool_input` is already parsed at `:104` — derive a
bounded preview for Bash/Edit/Write/MultiEdit) → `/hook/permission` + `pendingApprovals`
in `src/server.ts:423` (attach + re-send to late joiners, like `gate` already does) →
optional bounded `preview` on the `status` ServerMessage in `src/types.ts``manager`
→ approval bar in `public/tabs.ts` (reuse `public/diff.ts` renderer + `sanitizeField`).
Pure side-channel. **Effort: M.** Risk: truncate + strip control chars + cap bytes,
`textContent`/diff-render only, unknown tools fall back to today's name-only bar.
- [x] **Clickable URLs & file paths in the terminal**
Tap the dev-server URL or file path Claude prints instead of soft-keyboard copy gymnastics
(paths reuse `POST /open-in-editor`). TECH_DOC named `@xterm/addon-web-links` in v0.2; never built.
**Touches:** frontend only — `public/terminal-session.ts` (add the addon + a path-matcher
regex → `/open-in-editor`). Zero server change. **Effort: S.**
---
## Wave 2 — the unlocking primitive
- [x] **Server-side PTY-inject + queued follow-up prompt** _(unlocks a family)_
A thin Origin/loopback-guarded route that writes text into a session's PTY, plus a
per-session queue that fires one entry when Claude goes idle ("now run the tests", then
"now open a PR"). Walk-away = give a task and leave; queuing lets the session advance itself.
**Touches:** new `POST /live-sessions/:id/queue` calling the existing `writeInput`
(`src/session/session.ts:201`); a small queue in `manager.ts`; dequeue in the existing
Stop/SessionEnd hook branch (`src/server.ts:414`); FE near `public/quick-reply.ts`.
Injection is identical to a keystroke → byte-shuttle preserved, broadcasts to mirrors.
**Effort: M.** Risk: gate firing on `claudeStatus==='idle'` + settle delay; surface the
queue in UI. **Foundation for** templated launches, auto-continue, issue-intake (backlog).
---
## Wave 3 — read-only side-channel batch (review from the phone)
- [x] **Diff against a base branch** (`?base=<rev>`)
Review a whole agent branch vs `main` before landing, not just uncommitted changes.
`src/http/diff.ts` already deferred this (FR-B1.9) and named its mitigation:
`git rev-parse --verify` allow-list before any revision reaches the CLI.
**Touches:** optional `base` on `getDiff()` (guarded `git rev-parse --verify <base>` +
trailing `--`, then `git diff <base>...`); branch-picker in `public/diff.ts`. **Effort: S/M.**
- [x] **PR + CI/checks status via `gh`** (read-only)
Per-project/session chip: PR state · checks passing · mergeable — glance from the phone,
re-engage only when red. No `gh` usage exists in `src/` yet; `gh` emits JSON (no parsing pain).
**Touches:** new `src/http/gh.ts` (mirror `diff.ts`'s `runGit`); `GET /projects/pr?path=`
guarded by `isValidGitDir`; `PrStatus` in `src/types.ts`; FE chip in project detail.
Capability-probe + empty-degrade when `gh` absent/unauthed. **Effort: M.**
- [x] **Quick wins** (small, cheap, high-delight)
- [ ] **Sync chip on project cards** — ahead/behind + last-commit, folded into the existing
per-repo metadata pass in `src/http/projects.ts` (no new route). **S.**
- [ ] **Cost budget guard + push alert**`costUsd` already flows via statusLine
(`handleStatusLine`); add `COST_BUDGET_USD` + a one-shot latch (like `stuckNotified`) +
warn styling in `public/preview-grid.ts`. The rail that makes unattended auto-continue safe. **SM.**
- [ ] **"While you were away" reconnect digest** — read-side aggregate over `manager.list()`
+ telemetry/timeline/status → "3 done, 1 waiting, $6, 2 PRs" on reconnect. **SM.**
- [ ] **Recent-commits log per project**`git log --oneline -n N` (NUL-delimited) via a
guarded `GET /projects/log`; inert-text render. **SM.**
---
## Wave 4 — close the git loop
- [x] **Worktree lifecycle: remove / prune** (create is now fixed)
Delete losing worktrees + land the winner from any device — closes the create-only loop.
**Touches:** `removeWorktree`/`pruneWorktrees` in `src/http/worktrees.ts` (same execFile
no-shell + timeout, validate target in `git worktree list` & not main, reuse realpath
containment); `DELETE /projects/worktree` + `POST /projects/worktree/prune` (Origin-guarded);
make the existing `locked`/`prunable` tags actionable. **Effort: SM.** Risk: destructive —
require `--force` + confirm for dirty trees, reject the main worktree, safe error messages.
- [x] **Stage / commit / push from the diff viewer**
Claude's done, you reviewed on the phone — now commit + push without typing git into a
mobile terminal. Highest-risk git write; bound the MVP to per-file stage-toggle + commit +
push-current-branch only; **defer discard/checkout**; realpath-contain paths, cap msg length,
push only to existing upstream or `-u`, CSRF-guarded. **Effort: ML.**
---
## Wave 5 — bigger bets
- [x] **Worktree fan-out board** _(the north star: 真并行不互踩)_
Fan one task across N branch/agent lanes of one repo, watch them race, approve/kill per lane,
keep the winner. **Mostly composition of shipped parts**`createWorktree` + live-sessions +
the split-grid watch board + statusLine gauges + per-quadrant inline approve; server side is a
thin "sessions grouped by repo/worktree" endpoint. Cost is UI. **Effort: L.** Depends on Wave 4.
- [x] **App-level access token** (leave-the-LAN bar-raiser) — shipped on `develop`
A constant-time-compared (`crypto.timingSafeEqual` over SHA-256, fixed-length guard)
`WEBTERM_TOKEN` checked on the WS handshake (alongside, not replacing, Origin) + a central
gate over every remote HTTP route, set as an `HttpOnly; SameSite=Strict; Secure-when-https`
cookie after one-time `GET /?token=` or `POST /auth` (rate-limited 10/min/IP), disabled when
unset (keeps LAN zero-config byte-identical). Charset/length-validated at load; loopback
`/hook*` ingest exempt. `src/http/auth.ts` + wiring in `src/server.ts`.
**Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the
token is cleartext and replayable; only hardens the TLS-terminated relay/tunnel path. **Effort: M.**
- [x] **Android Projects / Diff / Worktree screens** (client parity)
Android's whole v0.6/v0.7 projects-git UI is SDK-gated/off in `settings.gradle.kts`.
Zero server change (the `:api-client` module already speaks the endpoints), but the largest
scope. Sequence *after* the server-side git features so it's one parity pass. **Effort: L.**
---
## Deferred backlog (your own recorded intent, surfaced from the docs)
Consciously punted in the planning docs; several become cheap once the two unlocking primitives
(PTY-inject, approval-preview) land.
| Feature | Deferred in | Note |
|---|---|---|
| Line-level review comments → agent | `FEATURE_WALKAWAY_WORKBENCH.md §B1.6 / US-B1.4` | Tap a diff line, type feedback, composed with `file:line` and sent via `TerminalSession.send`. No new route. Pairs with diff-vs-base. **M.** |
| GitHub issue → new session/worktree | `FEATURE_PROJECT_MANAGER.md §9` | Pick an issue → spawn a session (optionally fresh worktree) with title+body as the prompt. Rides PTY-inject + host `gh`. Treat issue text as untrusted bytes. **M.** |
| Templated one-tap launches (repo + prompt + mode) | implied by `attach.cwd` + Projects launchers | "Triage repo X in plan mode" as one tap. Rides PTY-inject + a template store. **M.** |
| Auto-continue on idle (bounded) | no idle automation today | Opt-in auto-inject "continue" up to N times under a cost ceiling. Rides PTY-inject + budget guard. Build last, low default cap, never auto-approve. **SM.** |
| Cross-session cost rollup / trends | `FEATURE_WALKAWAY_WORKBENCH.md §B2.6` | Bounded ring beside the latest-only field + `GET /telemetry/summary` + dashboard panel. Overlaps the budget-guard quick win. **M.** |
| Mission Control: cross-session feed + durable run log | (no cockpit persistence today) | Merged reverse-chron feed + append-only on-disk tail (reuse `subscription-store.ts` JSONL/atomic write, byte-capped) so overnight runs survive restart. The "while you were away" digest is its first slice. **ML.** |
| Per-project task backlog | task tracking unbuilt | Per-repo TODO store mirroring `prefs-store.ts`; one tap spawns Claude pre-filled. Local state, **not** a GitHub Issues sync (YAGNI). **M.** |
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | We already run as a tmux client on the user's default tmux server (sessions `web_<id>`), so `tmux ls` already sees manual/iTerm tmux sessions. Add a discovery list + "attach external tmux session" entry in the launcher → spawn a client PTY `tmux attach -t <name>`, wrapped in the session model. **SM.** |
---
_Kept in sync by the maintainer. Ideas are grounded against the code as of `develop` — verify
file/line references before starting (they drift)._

View File

@@ -0,0 +1,155 @@
# Approval preview — command/diff on the approval bar
## Summary & grounding
Today `/hook/permission` (`src/server.ts:423`) reads the hook body, extracts only `tool_name` (`server.ts:430`), derives a `gate` (`server.ts:454`), parks the held `res` in `pendingApprovals` (`server.ts:464`), and broadcasts a bare `waiting` status via `manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)` (`server.ts:471`). The rich `tool_input` — which `parseHookEvent` already knows how to pass through verbatim (`src/http/hook.ts:104-105`) — is dropped on this route. The status `ServerMessage` (`src/types.ts:113-119`) has no field to carry a preview, so the approval bar in `public/tabs.ts:360-377` can only say *"Claude wants to use Bash"*.
This feature derives a **bounded, sanitized preview** from `tool_input` server-side, threads it through the same broadcast + late-joiner re-send paths the `gate` already uses, and renders it above the Approve/Reject buttons — reusing the render-only diff renderer (`public/diff.ts:139` `renderDiffFile`) and the `sanitizeField` pattern (`src/session/timeline.ts:50`).
Design decision: the derive logic lives in a **new pure module** `src/http/approval-preview.ts` (not inlined in `hook.ts`) so it is unit-testable in isolation and keeps `hook.ts` focused; `hook.ts` is cited only as the proof that `tool_input` is already available on the hook body. The `/hook/permission` route calls it directly (it does not go through `parseHookEvent`).
---
## Contract
### New message field — `src/types.ts` (coordination edit)
Add a new exported type and extend the `status` variant of `ServerMessage` (currently `src/types.ts:113-119`). Additive + optional → older clients ignore it; the frontend exhaustiveness check (`terminal-session.ts:349-355`) is unaffected.
```ts
/** A5-adjacent: compact, BOUNDED preview of what a held approval will run.
* Derived server-side from the hook tool_input, sanitized + byte-capped.
* Discriminated on `kind`:
* - 'command' → a shell command string (Bash). Newlines PRESERVED; all other
* control/ANSI chars stripped. Rendered in a <pre> via textContent.
* - 'diff' → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
* rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
* `truncated` = the source exceeded the line/byte cap and was clipped. */
export type ApprovalPreview =
| { kind: 'command'; text: string; truncated?: boolean }
| { kind: 'diff'; file: DiffFile; truncated?: boolean };
```
Extend the status variant:
```ts
| {
type: 'status';
status: ClaudeStatus;
detail?: string;
pending?: boolean;
gate?: PermissionGate;
preview?: ApprovalPreview; // NEW — present only on a held (pending) waiting status
}
```
Extend `SessionManager.handleHookEvent` (currently `src/types.ts:331-339`) with a trailing optional param (appended last so the existing positional `/hook` call at `server.ts:412` is untouched):
```ts
handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview, // NEW
): void;
```
### New route behavior
No new route. `POST /hook/permission` (`server.ts:423`) gains: derive a preview from `body['tool_input']` + `tool`, store it on the `PendingApproval`, pass it into `handleHookEvent`. `GET`/other routes unchanged.
### New env vars — `src/config.ts`
**None.** The bounds are security limits, not user knobs (loosening them is a DoS/broadcast-bloat vector), so they are module constants in `approval-preview.ts`, not config. (Documented as a deliberate choice; if a knob is later wanted, `APPROVAL_PREVIEW_BYTES` slots into `Config` next to the existing `previewBytes` field.)
### Bounds (module constants in `src/http/approval-preview.ts`)
| Constant | Value | Purpose |
|---|---|---|
| `PREVIEW_MAX_LINES` | 40 | max diff/command lines emitted |
| `PREVIEW_MAX_LINE_LEN` | 200 | per-line char cap (= `sanitizeField` default) |
| `PREVIEW_MAX_BYTES` | 4096 | hard total-byte cap on the serialized preview payload |
| `EDIT_TOOLS` | `Edit`,`Write`,`MultiEdit`,`NotebookEdit` | reuse the set semantics from `timeline.ts:17` |
---
## Files to change
| Path | Change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `ApprovalPreview` type; add `preview?` to the `status` `ServerMessage` variant (`:113-119`); add trailing `preview?` param to `SessionManager.handleHookEvent` (`:331-339`). |
| `src/http/approval-preview.ts` | **New file (pure, no DOM, never throws).** `deriveApprovalPreview(toolName: string \| undefined, toolInput: unknown): ApprovalPreview \| null`. Bash→`{kind:'command'}`; Edit/Write/MultiEdit/NotebookEdit→`{kind:'diff', file}`; unknown tool / malformed input→`null`. Imports `sanitizeField` from `../session/timeline.js`; defines a `sanitizeLine` wrapper and per-line splitting so `\n`/`\t` survive but ANSI/control chars don't. |
| `src/server.ts` | In `/hook/permission` (`:423`): after computing `tool` (`:430`), call `deriveApprovalPreview(tool, body['tool_input'])`. Add `preview?: ApprovalPreview` to the `PendingApproval` interface (`:225-231`); store it at creation (`:464`). Pass `preview` into `handleHookEvent(...)` (`:471`). In the late-joiner re-send (`:858-864`) include `preview: heldApproval.preview`. Import `deriveApprovalPreview` + `ApprovalPreview`. |
| `src/session/manager.ts` | `handleHookEvent` (`:223-249`): accept trailing `preview?` param; set `msg.preview = preview` when defined, before `broadcast(session, msg)` (`:245-249`). (The Case-2 late-join re-send at `:142` stays bare — the server layer owns pending/preview re-send, per the comment at `:137-138`.) |
| `public/terminal-session.ts` | Add `private pendingPreviewValue: ApprovalPreview \| null = null` near `:98-106`; getter `get pendingPreview()` near `:185-207`; in `case 'status'` (`:313-322`) set `this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null`; clear it in the two disconnect resets near `:385-389`. Import `ApprovalPreview` from `../src/types.js`. |
| `public/tabs.ts` | In `updateApprovalBar` (`:360-377`): after the `label`, if `session.pendingPreview` is set, build and insert a preview node **before** the buttons. Add `private renderApprovalPreview(p: ApprovalPreview): HTMLElement``kind:'command'``<pre class="approval-cmd">` via `textContent`; `kind:'diff'``renderDiffFile(p.file)` wrapped in a scroll container; append a "… truncated" note when `p.truncated`. Import `renderDiffFile` from `./diff.js` and `ApprovalPreview` from `../src/types.js`. |
| `public/style.css` | Add `.approval-preview` (scroll container: `max-height`, `overflow:auto`, `overflow-x:auto`), `.approval-cmd` (`white-space:pre-wrap`, monospace), `.approval-truncated` under the existing `#approvalbar` block (`:1084`). Reuse existing `.df-*` styling for the diff. |
| `test/http/approval-preview.test.ts` | **New (node).** Unit tests for `deriveApprovalPreview`. |
| `test/manager.test.ts` | Extend: `handleHookEvent` with a `preview` arg puts it on the broadcast status msg. |
| `test/terminal-session.test.ts` | Extend (jsdom): status frame with `preview` sets `pendingPreview`; cleared when `pending` false / on disconnect. |
| `test/tabs.test.ts` | Extend (jsdom): `FakeTerminalSession` gains `pendingPreview`; `updateApprovalBar` renders command / diff / truncated note; no-preview → name-only bar (regression). |
| `test/integration/server.test.ts` | Extend: `POST /hook/permission` with `tool_input` → broadcast `waiting` status carries `preview`; a late-joining WS gets the preview on attach. |
---
## TDD steps (ordered)
Repo style: pure/back-end tests are plain vitest (node) — see `test/hook.test.ts` (`import { describe, it, expect }`, `expect.objectContaining`). Front-end tests carry `// @vitest-environment jsdom` and mock `TerminalSession` (`test/tabs.test.ts`) or mock `WebSocket` + stub xterm (`test/terminal-session.test.ts`). Diff render tests run in jsdom asserting `textContent` (`test/diff.test.ts`).
1. **RED — `test/http/approval-preview.test.ts` (node).** Write, before any impl:
- Bash: `deriveApprovalPreview('Bash', { command: 'ls -la', description: 'x' })``{ kind:'command', text:'ls -la' }`.
- Bash multi-line: `command:'a\nb'``text` still contains the `\n` (newlines preserved).
- Bash with ANSI/control injection: `command:'\x1b[31mrm\x1b[0m\x07'` → ESC/BEL stripped, no `\x1b`/`\x07` in `text`.
- Edit: `{ file_path:'/p/f.ts', old_string:'a\nb', new_string:'c' }``{ kind:'diff', file }` where `file.newPath` sanitized, hunk has `removed` lines `a`,`b` and `added` line `c`, `file.removed===2`, `file.added===1`.
- Write: `{ file_path, content:'x\ny' }` → all-added hunk, `removed===0`.
- MultiEdit: `{ file_path, edits:[{old_string,new_string},{...}] }` → one hunk per edit.
- Truncation: `content` with >`PREVIEW_MAX_LINES` lines → `truncated:true`, ≤ cap lines emitted; a >`PREVIEW_MAX_BYTES` blob → `truncated:true` and serialized size ≤ cap.
- Unknown tool (`'WebFetch'`) → `null`; missing/`null`/array/number `toolInput``null` and **never throws** (mirrors `hook.ts` SEC-M7 style).
2. **GREEN — implement `src/http/approval-preview.ts`.** Pure, `unknown`-narrowed, `sanitizeField`-per-line, byte-clamped. Run the suite to green.
3. **RED — `test/manager.test.ts`.** Add: calling `handleHookEvent(id,'waiting','Bash',true,'tool',undefined,undefined,{kind:'command',text:'ls'})` broadcasts a status msg whose `preview` deep-equals the arg (assert via the fake WS `send` capture already used in this file). Add: omitting `preview` → no `preview` key on the msg.
4. **GREEN — `src/types.ts` param + `src/session/manager.ts`.** Add the trailing param and `msg.preview` assignment.
5. **RED — `test/integration/server.test.ts`.** Add a test (follow the `itPty` pattern of ⑧ at `:608-655`): attach a WS, `POST /hook/permission` with `{ tool_name:'Bash', tool_input:{ command:'echo hi' } }`, assert the broadcast `pending===true` status has `preview.kind==='command'` and `preview.text` containing `echo hi`. Add a **late-joiner** assertion: open a 2nd WS to the same `sessionId` while held → its first `waiting/pending` status includes `preview`.
6. **GREEN — `src/server.ts`.** Wire `deriveApprovalPreview` into `/hook/permission`, store on `PendingApproval`, pass to `handleHookEvent`, include in the `:858-864` re-send.
7. **RED — `test/terminal-session.test.ts` (jsdom).** Feed a `status` frame with `pending:true, gate:'tool', preview:{kind:'command',text:'ls'}``session.pendingPreview` set; a follow-up `status` with `pending:false` clears it to `null`; disconnect clears it.
8. **GREEN — `public/terminal-session.ts`.** Add field, getter, set/clear.
9. **RED — `test/tabs.test.ts` (jsdom).** Extend `FakeTerminalSession` with `pendingPreview`. Assert `updateApprovalBar`: command preview → a `.approval-cmd` node whose `textContent` equals the command; diff preview → a `.df-file` present (renderDiffFile output); `truncated:true``.approval-truncated` present; `pendingPreview:null` → bar shows label + buttons only (regression, matches `:373-374`). Assert **zero `innerHTML`** — content via `textContent` only.
10. **GREEN — `public/tabs.ts` + `public/style.css`.** Add `renderApprovalPreview`, insert before buttons, style the container.
11. **Coverage gate.** The new pure module is branch-dense and fully unit-covered (helps the 80% gate); FE branches covered by jsdom tests. Run full `npm test` + coverage; confirm no regression in `hook.test.ts`/`diff.test.ts`/`manager.test.ts`.
---
## Edge cases & failure modes
- **Unknown / non-preview tool** (WebSearch, Task, MCP tools, `ExitPlanMode`): `deriveApprovalPreview` returns `null` → status carries no `preview``updateApprovalBar` falls back to today's name-only bar (`:373`). Plan-gate (`gate:'plan'`, `:369-371`) also gets no preview (ExitPlanMode has no reviewable command/diff) — unchanged.
- **Missing/partial `tool_input`**: Bash without `command`, Edit without `old_string`, `tool_input` present but `null` (the `'tool_input' in b` passthrough at `hook.ts:104` can yield `null`) → return `null`, never throw.
- **Huge command / whole-file Write**: clipped at `PREVIEW_MAX_LINES` then `PREVIEW_MAX_BYTES`; `truncated:true` shows the "… truncated" note. Prevents a multi-MB status frame from being broadcast to every client and retained in `pendingApprovals`.
- **Newlines vs control chars**: `sanitizeField` (`timeline.ts:50`) strips `\x00-\x1f` which includes `\n`/`\t`/`\r` — so it is applied **per line after splitting**, never to the whole multi-line blob, preserving structure while still killing ANSI ESC (`\x1b`) and BEL.
- **MultiEdit with many edits**: hunks accumulate until the line/byte cap, then stop + `truncated:true`.
- **Late joiner after approval already resolved**: `pendingApprovals.get()` returns `undefined` (`server.ts:858`) → no preview re-sent (correct; nothing is held).
- **Approve/reject clears preview**: `handleHookEvent(...,'working',...,false)` (`server.ts:887,890`) broadcasts a non-pending status → `terminal-session` sets `pendingPreview = null` → bar hides (existing `:362` guard).
- **Multi-device**: preview broadcasts to all clients via `broadcast` and re-sends to each new attach — every mirror shows the same preview.
- **Binary/odd content in Write**: rendered as literal text via `textContent` (no interpretation), `binary:false` on the synthetic file (we don't attempt binary detection — out of scope).
---
## Security
- **Trust boundary**: `tool_input` is Claude-controlled content arriving on the loopback-only `/hook/permission` route (`isLoopback` guard at `server.ts:424`). It is untrusted for *content*: treat as `unknown`, narrow every field (`typeof x === 'string'`), never index without a guard. `deriveApprovalPreview` must **never throw** (SEC-M7 discipline, mirroring `parseHookEvent`).
- **Sanitization (SEC-H6 reuse)**: every emitted string passes through `sanitizeField`/`sanitizeLine` — strips `\x00-\x1f` (incl. ANSI ESC `\x1b`), truncates to `PREVIEW_MAX_LINE_LEN`. This neutralizes terminal-escape / cursor-hijack payloads in a filename or command before they reach the DOM.
- **XSS (SEC-H4 reuse)**: the frontend renders **only** via `textContent` / `el()` / `renderDiffFile` (which is already innerHTML-free — `diff.ts:9`, `:191-194`). No `innerHTML` anywhere in the new FE code; asserted in tests. `<script>`, `&`, `<img onerror>` appear as literal characters.
- **DoS / resource containment**: `PREVIEW_MAX_LINES` + `PREVIEW_MAX_BYTES` cap the payload that is (a) broadcast to N clients and (b) retained in `pendingApprovals` for the held-decision lifetime. No unbounded growth from a hostile/huge `tool_input`.
- **No new route, no new capability token**: the existing per-decision token (`server.ts:457`), Origin/loopback guards, and rate limiters are untouched. Preview is pure display data attached to an already-authorized held decision.
- **No path egress / no argv**: file paths from `tool_input` are only sanitized + displayed as text; they are never passed to `execFile`, `fs`, or a shell. No path-traversal surface is added.
---
## Effort & dependencies
- **Effort**: ~1.52 days. Breakdown: pure `approval-preview.ts` + its tests ~0.5d (the bulk of logic + coverage); server/manager/types threading ~0.25d; FE (terminal-session field + tabs render + CSS) + jsdom tests ~0.5d; integration test + polish ~0.25d.
- **Depends on** (all already shipped): H3 held-approval gate + `pendingApprovals` (`server.ts:423-472`); B4 `gate` re-send plumbing to late joiners (`server.ts:858-864`, `manager.ts:137-142`) — this feature rides the exact same rails; B1 `public/diff.ts` `renderDiffFile` + the `DiffFile`/`DiffLine` types (reused, not modified); A4 `sanitizeField` (`timeline.ts:50`, reused).
- **Unlocks / synergizes**: the diff-render path here is the same one W3 "Diff against a base branch" and W4 "Stage/commit/push from the diff viewer" extend — a shared, security-reviewed `DiffFile` render surface. Also complements A1 lock-screen approvals: a future enhancement can put a one-line preview summary into the push `detail` (`PushPayload.detail`, `types.ts:381`) so the phone shows *what* is being approved (not planned here, but the derive function is the reusable source).
- **Isolation**: `src/http/approval-preview.ts` is a new owned file (no conflict); `src/types.ts` is the one coordination edit (additive-optional, low collision risk); `server.ts`/`manager.ts`/`terminal-session.ts`/`tabs.ts` edits are localized to the cited line ranges.

View File

@@ -0,0 +1,143 @@
# Clickable URLs & file paths in the terminal
**Status of premise (read first).** Two independent capabilities are bundled here:
1. **URLs** — already *90% shipped.* `public/terminal-session.ts:148` already does `this.term.loadAddon(new WebLinksAddon())`, and `@xterm/addon-web-links@^0.12.0` is already in `package.json`. Remaining work is a security hardening pass on link activation (scheme allowlist + `noopener`). Pure frontend.
2. **File paths → editor** — needs a **custom link provider** *and* a **small, additive server change.** The task brief says "POST to the existing `/open-in-editor` … no server change," but the existing route **cannot** open a file at a line: `openInEditor` (`src/http/editor.ts:31`) rejects anything that is not an **absolute existing _directory_** (lines 35, 4547) and spawns `code <dir>` with **no `--goto`/line** (line 51). Feeding it `src/app.ts:42` fails three validators at once. See the **Decision** callout below — I recommend a tiny additive `openFileInEditor` alongside the untouched `openInEditor`. A strict "frontend-only" fallback exists but degrades to "open the containing folder, no line jump."
`src/types.ts` is **not** touched — this is an HTTP JSON body, not a WS protocol message; no shared-contract change. The byte-shuttle terminal stream is untouched.
---
## Decision: how file-path clicks reach the editor
| Option | What clicking `src/app.ts:42` does | Server change | Recommendation |
|---|---|---|---|
| **A — additive `openFileInEditor` (recommended)** | Opens the file at line 42 (`code --goto /abs/src/app.ts:42`) | +~35 lines in `src/http/editor.ts`, +1 branch in the `server.ts:384` route. Backward-compatible; `openInEditor` + its tests untouched | **Yes.** Only option that delivers the actual feature (jump to file:line). Additive and low-risk. |
| **B — strict frontend-only** | Resolves to the file's parent dir and calls existing `openInEditor` → opens the *repo/folder*, no file, no line | None | Fallback only. Poor UX; loses the whole point (line jump). Document but don't ship as primary. |
The rest of this plan assumes **Option A**. The frontend work is identical either way; only the POST body and server branch differ.
---
## Contract
### Route (extended, backward-compatible)
`POST /open-in-editor` (`src/server.ts:384`) — unchanged guards: `express.json({ limit: '4kb' })`, `requireAllowedOrigin` (CSRF, `src/server.ts:352`). New branch on body shape:
- **Existing (directory)** — `{ "path": "<abs dir>" }``openInEditor(cfg, body.path)` (unchanged; Projects panel keeps working).
- **New (file+line)** — `{ "file": "<abs file>", "line": <int?>, "column": <int?> }``openFileInEditor(cfg, body.file, body.line)`.
- Response envelope unchanged: `204` on success; `{ error }` + `4xx/5xx` on failure (mirrors `src/server.ts:388392`).
Body is a discriminated request: **`file` present ⇒ file mode; else path mode.** If neither present → `400 { error: 'path or file is required' }`.
### New server function (`src/http/editor.ts`)
```
export async function openFileInEditor(
cfg: Config, rawFile: unknown, rawLine?: unknown
): Promise<OpenEditorResult>
```
Reuses the existing `OpenEditorResult` type (`editor.ts:20`). Validates: string + non-empty (`400`), `path.isAbsolute` (`400`), `fs.stat` exists (`404`), `stat.isFile()` (`400 'path is not a file'`), and `line` (when present) is an integer in `1..1_000_000` else `400`. Spawns via `execFile(cfg.editorCmd, args)` (no shell, same as line 51) where:
- `args = isGotoEditor(cfg.editorCmd) && line !== undefined ? ['--goto', `${file}:${line}`] : [file]`
- `isGotoEditor` = basename ∈ `{code, code-insiders, codium, cursor, windsurf}` (the editors that accept `--goto`). Unknown editors open the bare file (never pass a bogus `--goto` argv).
### New frontend module (`public/link-paths.ts`) — pure, node-testable
```
export interface PathMatch {
text: string // exact matched substring (e.g. "src/app.ts:42")
path: string // "src/app.ts"
line?: number
column?: number
startX: number // 1-based column of first char (xterm range.start.x)
endX: number // 1-based column of last char (xterm range.end.x, inclusive)
}
export function findPathMatches(lineText: string): PathMatch[]
```
Matching rules (concrete): a token is a path candidate iff it has a filename with a dot-extension, optionally preceded by `./`, `../`, or `dir/…/` segments, optionally suffixed `:line` and `:line:col`. A candidate becomes a match iff **(has a `/` separator) OR (has a `:line` suffix) OR (extension ∈ `CODE_EXT` allowlist)** — this links `src/app.ts:42`, `README.md`, `main.rs:10` while rejecting `example.com`, `v1.2`, `foo.bar`. Reject candidates immediately preceded by `/` or `:` (avoids grabbing the tail of a `https://host/path.html` URL, which `WebLinksAddon` owns). `CODE_EXT` = a named const set (`ts tsx js jsx mjs cjs py go rs rb java kt c h cpp hpp cc cs php swift css scss html json yaml yml toml md txt sh sql vue svelte` …).
### Env vars
**None new.** `EDITOR_CMD` (default `'code'`) already exists (`src/config.ts:49`, `src/types.ts:42`) and is reused.
### WS protocol / `src/types.ts`
**No change.** No new client→server or server→client message types.
---
## Files to change
| Path | Change |
|---|---|
| `public/link-paths.ts` | **New.** Pure `findPathMatches` + `CODE_EXT` const + `PathMatch` type. No DOM. (Keeps matcher unit-testable in node and file <150 lines.) |
| `public/terminal-session.ts` | Replace bare `new WebLinksAddon()` at **line 148** with a hardened handler (scheme allowlist + `window.open(uri,'_blank','noopener,noreferrer')`). After `term.open` (line 149) register a path link provider via `this.term.registerLinkProvider(...)`; store the returned `IDisposable`. Add `private openPath(m: PathMatch)` (resolve relabs via `this.cwdValue`, in-flight guard, `fetch('/open-in-editor', …)`, error`statusLine` toast). Dispose the provider in `dispose()` (line 452). Small helpers `openWebLink`, `makePathLinkProvider`. |
| `src/http/editor.ts` | **Add** `openFileInEditor` + `isGotoEditor` helper. **`openInEditor` unchanged** (Projects panel + its tests keep passing). |
| `src/server.ts` | In the `/open-in-editor` handler (**line 384393**) branch: `body.file` present `openFileInEditor(cfg, body.file, body.line)`; else existing `openInEditor(cfg, body.path)`. |
| `src/types.ts` | **Not touched** noted here only to confirm no shared-contract edit is required. |
---
## TDD steps (ordered)
> Run with `npm test` (vitest). New frontend-logic tests are **node** (pure matcher); DOM-wiring tests reuse the existing **jsdom** harness in `test/terminal-session.test.ts`.
1. **`test/link-paths.test.ts` (new, node).** REDGREEN for `findPathMatches`:
- `'see src/app.ts:42 for'` one match, `path:'src/app.ts', line:42`, `startX/endX` correct (1-based, `startX = index+1`, `endX = index+len`).
- `'./a/b.tsx:10:5'` `line:10, column:5`.
- `'README.md'` (allowlisted ext, no slash) matched; `'example.com'` and `'v1.2.3'` **no** match.
- URL guard: `'https://host/path.html'` **no** path match (preceded-by-`/` rule).
- Two paths on one line two matches with disjoint ranges.
Then implement `public/link-paths.ts` to green.
2. **`test/editor.test.ts` (extend, node mirror existing spawn-a-harmless-process style, `editorCmd:'true'`).** Add a `describe('openFileInEditor')`:
- relative `400`; missing `404`; **directory** `400 'is not a file'`; non-int/`0`/`1e9+` line `400`.
- success on a real temp file (editorCmd `'true'`) `status 204`.
- **argv assertion:** write a tiny recorder script into the temp dir (`#!/bin/sh; printf '%s\n' "$@" > "$ARGS_OUT"`), set `editorCmd` to it, call with `line:42`, assert the recorded argv is `--goto`, `<file>:42`; call an unknown editor name argv is just `<file>` (no `--goto`).
Then implement `openFileInEditor` + `isGotoEditor` to green.
3. **`test/integration/server.test.ts` (extend; harness at line 229).** Boot the app, `POST /open-in-editor`:
- `{file:<abs tmp file>, line:3}` with a valid `Origin` `204`.
- foreign/missing `Origin` `403` (proves the CSRF guard still covers the new branch).
- `{path:<abs tmp dir>}` still `204` (regression: directory mode intact).
Wire the `server.ts` branch to green.
4. **`test/terminal-session.test.ts` (extend, jsdom).** Extend `FakeTerminal` (line 14) with `registerLinkProvider = vi.fn(p => { this.captured = p; return {dispose:vi.fn()} })` and a `buffer = { active: { getLine: (y)=>({ translateToString:()=> this.lineText }) } }`. Keep the `WebLinksAddon` mock (line 48) but assert the constructor **received a handler fn**. Tests:
- after construct, a link provider is registered; feeding a line with `src/app.ts:42` `provideLinks` callback yields one `ILink` whose `text/range` match `findPathMatches`.
- calling `link.activate(mouseEvent, text)` when `cwd` is set (drive an OSC-7 via the captured handler, or set via a resolved path) `fetch` (stub via `vi.stubGlobal('fetch', …)` as in `test/preview-grid.test.ts:140`) called once with `'/open-in-editor'`, method `POST`, body `{file:<abs>, line:42}`.
- relative path **with null cwd** **no** fetch; a `statusLine` is written to the terminal (assert `term.write`).
- in-flight guard: two rapid `activate` calls **one** fetch.
- `openWebLink('javascript:alert(1)')` `window.open` **not** called; `openWebLink('https://x')` `window.open('https://x','_blank','noopener,noreferrer')` called.
Wire `terminal-session.ts` to green.
**Coverage:** the matcher (branch-heavy) is fully covered by the node test; the DOM wiring by jsdom; the server branch + validators by editor/integration tests. This keeps the 80% gate comfortably.
---
## Edge cases & failure modes
- **No cwd yet** (OSC-7 never fired, `this.cwdValue === null`, `terminal-session.ts:97`) relative paths are unresolvable **skip activation**, write a one-line `statusLine('cannot open <path>: working dir unknown')`. Absolute paths still work.
- **Path doesn't exist / is a dir** server returns `404`/`400`; frontend shows a non-blocking `statusLine` toast, no throw (mirror the existing `openProjectInEditor` catch that only `console.error`s).
- **Wide (CJK) glyphs before a path** shift xterm columns vs JS string index. Paths are ASCII, but a preceding CJK run offsets `startX`. Acceptable v1 caveat; note it. (Fixable later by walking cells; YAGNI now.)
- **URL/path overlap** (`https://host/a.ts:5`) `WebLinksAddon` links the URL; the `/`-preceded guard stops the path provider from double-linking the tail. Verify the two providers don't both underline.
- **`provideLinks` line indexing** pass `terminal.buffer.active.getLine(bufferLineNumber - 1)` and set `range.{start,end}.y = bufferLineNumber` (xterm gives a 1-based buffer row). **Verify once in a real browser** the single indexing footgun.
- **Rapid clicks** spawn N detached GUI processes on the host in-flight boolean guard (+ optional 500 ms cooldown) on the frontend.
- **Non-`code` editor** without `--goto` support `isGotoEditor` returns false open bare file (never inject a stray `--goto` argv the editor would treat as a filename).
- **Line-only false positives** like `12:34` (a timestamp) filtered because the token needs a filename-with-extension before the `:line`.
---
## Security
- **Origin/CSRF:** unchanged. `/open-in-editor` stays behind `requireAllowedOrigin` (`src/server.ts:385`, `:352`). The frontend POST is same-origin, so the browser sends `Origin` and passes; a foreign page's no-preflight POST is rejected `403`. Integration test #3 asserts this on the new branch.
- **No shell / no injection:** `openFileInEditor` uses `execFile` with an **argv array** (as `editor.ts:51`); the file path and `<file>:<line>` are argv elements, never a command line. `line` is validated to an integer before interpolation, so `${file}:${line}` can't smuggle shell metacharacters via the line field.
- **Path containment:** the resolved path is validated **absolute + existing + `isFile()`** server-side. Terminal output is attacker-influenced (a malicious repo could print `../../etc/hosts:1`), but opening a file the user could already `cat` in the shell this app *already grants* adds no privilege (threat model: LAN, no auth, full shell). Optional hardening (note, not required v1): reject resolved paths that escape the session `cwd` root.
- **URL activation hardening (the real new surface):** custom `WebLinksAddon` handler (replacing the default at line 148) **allowlists schemes** to `http:/https:/mailto:` and opens with `window.open(uri, '_blank', 'noopener,noreferrer')` blocks `javascript:`/`data:`/`file:` URIs and prevents reverse-tabnabbing. Activation is gated on the click (a genuine user gesture); no hover/auto-open.
- **Rate-limit:** frontend in-flight guard caps editor-spawn fan-out; the route's `express.json({limit:'4kb'})` bounds body size. No new secrets, no logging of paths beyond the existing `console.error` on failure.
---
## Effort & dependencies
- **Effort:** ~**1.52 days.** Matcher + tests (0.5d), frontend wiring + jsdom tests + hardened URL handler (0.5d), server `openFileInEditor` + editor/integration tests (0.5d), manual browser verify of link indexing/overlap (0.25d).
- **Depends on:** nothing OSC-7 `cwd` capture (`terminal-session.ts:155159`), the `/open-in-editor` route, and the `WebLinksAddon` dependency all already exist. Self-contained, W1.
- **Unlocks / synergy:** the **Approval preview (W1, task #7)** and **diff viewer (W4, task #13)** can reuse `findPathMatches` + `openFileInEditor` to make paths in a diff/command preview clickable. `link-paths.ts` is deliberately a standalone pure module for that reuse.
- **Scope flag for the orchestrator:** Option A adds a ~35-line server function (contradicting the brief's "no server change"). It is additive and backward-compatible, but it *is* a deviation record it in `PROGRESS_LOG.md`. If the "no server change" constraint is hard, ship Option B (folder-open fallback) and defer file:line jump.

View File

@@ -0,0 +1,138 @@
# Server-side PTY-inject + idle-queued follow-up prompt
**Feature id:** `w2-pty-inject-queue` · **Branch base:** `develop`
A thin, Origin/CSRF-guarded HTTP route writes text into a live session's PTY, plus a **bounded per-session queue** whose head entry fires **once** when Claude next goes idle (Stop/SessionEnd), after a short **settle delay**. This is the unlocking primitive for templated launches, auto-continue, and issue-intake.
Grounding facts from the code I read:
- `writeInput(session, data)``src/session/session.ts:201` — no-op after PTY exit (L4); today called only from the WS input handler at `src/server.ts:876`.
- Idle is observable in the hook side-channel: the Stop/SessionEnd branch is `src/server.ts:414` (`if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd')`), immediately after `manager.handleHookEvent(...)` at `:412`. `handleHookEvent` (`src/session/manager.ts:223`) sets `session.claudeStatus` and broadcasts.
- `broadcast(session, msg)``src/session/session.ts:52` — fan-out to all `session.clients`.
- CSRF helper `requireAllowedOrigin(req, res)``src/server.ts:352`; per-IP `createRateLimiter(max, windowMs)``src/server.ts:109`; `isLoopback``src/server.ts:151`; `SESSION_ID_RE` (UUID v4, M7) — `src/protocol.ts:22`.
- Timer/hold precedent: `pendingApprovals` map + `setTimeout` live in **server.ts** (`:232`, `:459`), not the manager — the manager owns session state; the server owns wiring/timers. The queue follows the same split.
- Session shape `src/types.ts:201`; `LiveSessionInfo` `:246`; `ServerMessage` union `:109`; `SessionManager` iface `:314`. `timeline`/`stuckNotified`/`telemetry` are the precedent for "mutable runtime handle on immutable meta, replaced wholesale".
---
## Contract
### New HTTP routes (all in `src/server.ts`, registered near the `/live-sessions/:id/preview` block ~`:334`)
| Method / path | Guard | Body | Success | Errors |
|---|---|---|---|---|
| `POST /live-sessions/:id/queue` | `requireAllowedOrigin` (CSRF) + per-IP rate limit + `SESSION_ID_RE` check | `{ text: string, appendEnter?: boolean }` (`express.json({limit:'16kb'})`) | `200 { length }` | `400` bad id / empty text / non-string; `413` text > `QUEUE_ITEM_MAX_BYTES`; `404` unknown or exited session; `409` queue at `QUEUE_MAX_ITEMS`; `429` rate; `503` when `QUEUE_ENABLED=false` |
| `GET /live-sessions/:id/queue` | none (read-only, same threat model as `/live-sessions`) | — | `200 { length, items: string[] }` | `404` unknown |
| `DELETE /live-sessions/:id/queue` | `requireAllowedOrigin` | — | `200 { length: 0 }` (escape hatch: cancel all pending) | `404` unknown |
Not loopback-gated (unlike `/hook`): these are LAN-device actions, so Origin-guarded like `/open-in-editor` (`:384`).
### `src/types.ts` (coordination edit — the frozen shared-contract source)
- **`ServerMessage`** — add a variant so all attached devices see pending count live:
`| { type: 'queue'; length: number }`
- **`Session`** — add a mutable runtime handle (precedent: `timeline`):
`queue: readonly string[]` (verbatim byte strings, head fires first; replaced wholesale, never mutated in place).
- **`LiveSessionInfo`** — add optional `readonly queueLength?: number;` (additive/optional like `lastOutputAt` at `:261`) so `/live-sessions` and the manage grid show depth.
- **`SessionManager`** — add three methods:
- `enqueueFollowup(id: string, text: string): EnqueueResult`
- `drainOne(id: string): string | null` *(pops head, writes to PTY, broadcasts; null if none/exited)*
- `clearQueue(id: string): boolean`
- New result type:
`export type EnqueueResult = { ok: true; length: number } | { ok: false; reason: 'unknown' | 'full' | 'exited' };`
### `src/config.ts` env vars (add to `Config` in types.ts `:21` block **and** `loadConfig`)
| Env | Field | Default | Parser (existing helper) |
|---|---|---|---|
| `QUEUE_ENABLED` | `queueEnabled: boolean` | `true` | `parseBool` (`config.ts:89`) |
| `QUEUE_MAX_ITEMS` | `queueMaxItems: number` | `10` | `parseNonNegativeInt` (`:73`) |
| `QUEUE_ITEM_MAX_BYTES` | `queueItemMaxBytes: number` | `4096` | `parseNonNegativeInt` |
| `QUEUE_SETTLE_MS` | `queueSettleMs: number` | `1500` | `parseNonNegativeInt` |
### Client→server protocol
No new `ClientMessage`. Enqueue is HTTP POST (Origin-guarded), deliberately **not** a WS frame — it must survive "walked away, zero tabs open" and be usable from the manage page for any session, not just the WS-bound one. (Contrast: quick-reply chips send *immediately* over the active WS via `sendToActive`; the queue is *deferred* + cross-session, so HTTP is the right seam.)
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `queue` to `Session`; `queueLength?` to `LiveSessionInfo`; `{type:'queue';length}` to `ServerMessage`; 4 `Config` fields; 3 `SessionManager` methods; `EnqueueResult` type. |
| `src/config.ts` | Parse the 4 new env vars in `loadConfig` (helpers already exist); include in returned `Config`. |
| `src/session/session.ts` | Init `queue: Object.freeze([])` in the `createSession` session literal (~`:137`, beside `timeline`). No other logic here — reuse existing `writeInput` (`:201`) and `broadcast` (`:52`). |
| `src/session/manager.ts` | Implement `enqueueFollowup` / `drainOne` / `clearQueue`; add `queueLength: s.queue.length` to `list()` map (`:184`); import `writeInput` from `./session.js` (add to the existing import at `:43`). Add the three names to the returned object (`:337`). |
| `src/server.ts` | Register the 3 routes; add a `QUEUE_RATE_MAX` const + a `createRateLimiter` instance (beside `:218`); in the Stop/SessionEnd branch (`:414`) call a new local `scheduleDrain(sessionId)` that debounces a `setTimeout(cfg.queueSettleMs)` (map `drainTimers: Map<string, Timeout>`, `.unref()`); on fire, re-check idle + stable `lastOutputAt`, then `manager.drainOne(id)`. Clear all `drainTimers` in `doShutdown` (`:930`). |
| `public/queue.ts` **(new)** | Tiny FE module: `enqueueFollowup(sessionId, text, appendEnter): Promise<Result>` — POSTs `/live-sessions/:id/queue` (same-origin), never throws; `clearQueue(sessionId)`; parse `{type:'queue'}` frames to update a badge. |
| `public/tabs.ts` | Handle the incoming `{type:'queue'}` frame (near the existing status/telemetry frame handling) to show a "N queued" badge on the tab; add an "Queue…" affordance (long-press on the quick-reply `+`, or a small button) that calls `enqueueFollowup(this.activeSessionId(), text)` instead of `sendToActive` (`:875`). |
| `public/manage.*` (grid) | Optional: render `queueLength` per card and a cancel (DELETE) button. |
---
## TDD steps (ordered — RED → GREEN, matching repo style)
1. **`src/types.ts`** — make the coordination edit first so everything compiles. Update **every** test `CFG` fixture that lists all Config fields (`test/manager.test.ts:47`, and the same literal in `test/session.test.ts`, plus any others — `grep -rl "worktreeTimeoutMs" test/`) to add the 4 new fields. (Compile gate, no assertion.)
2. **`test/config.test.ts`** (node) — RED: assert defaults (`queueEnabled=true`, `queueMaxItems=10`, `queueItemMaxBytes=4096`, `queueSettleMs=1500`), env overrides parse, and invalid (`QUEUE_MAX_ITEMS=-1`) throws (fail-fast, like existing `parseNonNegativeInt` tests). → GREEN in `src/config.ts`.
3. **`test/manager.test.ts`** (node, node-pty mocked via `createMockPty`) — RED then GREEN in `src/session/manager.ts`:
- `enqueueFollowup` appends → returns `{ok:true,length:1}` and **broadcasts** `{type:'queue',length:1}` to a stub `WebSocketLike` (assert `ws.send` payload via `serialize`). Second call → `length:2`.
- Cap: with `queueMaxItems:2`, third enqueue → `{ok:false,reason:'full'}`, no broadcast, queue unchanged (immutability).
- Unknown id → `{ok:false,reason:'unknown'}`.
- `drainOne` on a 2-item queue → returns head string, asserts **`mockPty.write` called with that exact string**, queue now length 1, broadcasts `{type:'queue',length:1}`.
- `drainOne` empty queue → `null`, no write. Exited session (`session.exitedAt` set) → `null` (double-guards L4).
- `clearQueue` → empties + broadcasts `length:0`, returns true.
- `list()` includes `queueLength`.
*(Use the existing mock-pty `write` spy pattern from `test/session.test.ts:364`.)*
4. **`test/integration/queue.test.ts`** (new, node, real `startServer` + `fetch`, PTY-gated with the `itPty` helper at `server.test.ts:51`) — RED then GREEN for the routes in `src/server.ts`:
- `POST /live-sessions/:id/queue``403` foreign Origin; `403`/missing Origin default-deny (mirror `server.test.ts:770`).
- Allowed Origin, malformed id → `400`; empty `text``400`; `text` of `queueItemMaxBytes+1``413`; unknown session → `404`; over rate → `429`.
- Happy path on a **real attached** session (open WS, attach, capture `sessionId`): `200 {length:1}`, then `GET /live-sessions` shows `queueLength:1`; `DELETE …/queue``queueLength:0`.
- **Idle-drain wiring** (`itPty` + `vi.useFakeTimers`): attach real session, enqueue `"echo QUEUED_MARKER\r"`, POST `/hook` (loopback) with `{hook_event_name:'Stop', ...}` and header `x-webterm-session`, advance timers past `queueSettleMs`, assert the client WS receives an `output` frame containing `QUEUED_MARKER` (the shell echoes it). Also assert a *second* enqueue does **not** fire until the next Stop (one-per-idle pacing).
- Settle guard: enqueue, POST Stop, then before `queueSettleMs` push a `/hook` event that produces output (changes `lastOutputAt`) → advance timers → assert **no** drain (Claude still active). *(This targets the `scheduleDrain` cursor check.)*
5. **`test/queue.test.ts`** (new, **jsdom**, mocked `fetch`) — RED then GREEN in `public/queue.ts`:
- `enqueueFollowup` POSTs to the right URL/body, returns parsed result; on non-2xx returns `{ok:false}` and **never throws**; on network reject returns `{ok:false}` (mirrors quick-reply's never-throw discipline, `quick-reply.ts:99`).
- A `{type:'queue',length:3}` frame → badge helper returns/sets 3.
6. **`test/tabs.test.ts`** — extend: a `{type:'queue',length:N}` server frame updates the active tab's badge; the enqueue affordance calls `enqueueFollowup` with `activeSessionId()` (not `sendToActive`).
**Coverage:** manager + config + routes are node-testable deterministically (queue mutation, caps, broadcasts, drain, rate/Origin/validation all hit without a real PTY). The FE module is small and fully jsdom-mockable. Only the real echo-through-PTY assertion is `itPty`-gated (auto-skips in sandbox, runs in CI) — keeps the 80% gate.
---
## Edge cases & failure modes
- **Idle flapping / repeated Stop:** `scheduleDrain` **debounces** — clears any existing `drainTimers` entry and restarts the settle timer on each Stop; only fires once the window elapses.
- **New output during settle window:** capture `outputCursor = session.lastOutputAt` at schedule time; on timer fire, drain **only if** `session.lastOutputAt === outputCursor` **and** `claudeStatus === 'idle'` **and** `exitedAt === null`. Otherwise skip (a later genuine Stop reschedules). Prevents injecting mid-render.
- **One-per-idle pacing (intended):** `drainOne` fires exactly one entry; the injected prompt makes Claude work again → its next Stop drains the next entry. If Claude errors and never emits Stop, remaining items **wait** (no spamming).
- **Session exits with items queued:** `drainOne` guards on `exitedAt` (returns null); on session removal (`onSessionExit` L2 / `killById`) the queue dies with the session. Server clears its `drainTimers` entry in `doShutdown`; stale timers are harmless (drain returns null) and `.unref()`ed.
- **Queue full → `409`** (never silently drop). **Oversized text → `413`.** Both actionable to the caller.
- **Concurrent enqueue from two devices:** single-threaded, immutable array replace → both land; `{type:'queue'}` broadcast keeps every device's badge consistent.
- **Enqueue to exited/unknown session → `404`** (checked before append).
- **`QUEUE_ENABLED=false`** → routes `503` (graceful disable, like `/push/vapid-key:478`); Stop branch skips scheduling.
- **Verbatim bytes / Enter:** queue stores the exact string (byte-shuttle invariant). FE decides `appendEnter` (append `\r`) at enqueue time, mirroring quick-reply's `appendEnter` (`quick-reply.ts:24`). No server-side text parsing.
---
## Security
- **CSRF:** `POST`/`DELETE …/queue` are state-changing and cause **shell input**, so they carry `requireAllowedOrigin` (`:352`) — the same guard as the DELETE-session routes. Without it a foreign page could inject commands into a running Claude. `GET` is read-only (queue length + prompt text the user themselves queued) → no guard, consistent with `/live-sessions`.
- **Not loopback-gated:** intentionally Origin-gated (LAN device), not `isLoopback``/hook` is loopback (host-only) but enqueue must work from the phone.
- **Path/ID containment:** validate `:id` against `SESSION_ID_RE` (`protocol.ts:22`) before any Map lookup or PTY write; reject non-UUID with `400`. The id is only a Map key — never touches argv/fs.
- **Input validation at the boundary:** `text` must be a non-empty `string`; byte length (`Buffer.byteLength`) ≤ `queueItemMaxBytes` → else `413`. `appendEnter` coerced to boolean. Body capped by `express.json({limit:'16kb'})`. Bytes are passed **verbatim** to the PTY (raw keyboard bytes — do not filter content, per the protocol rule), but bounded in size and count.
- **Rate limit:** dedicated per-IP `createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (e.g. 20/min) → `429`, matching the `DECISION_RATE_MAX`/`SUBSCRIBE_RATE_MAX` pattern (`:75`). Bounds injection-flood risk.
- **DoS bounds:** `queueMaxItems` caps depth; `queueItemMaxBytes` caps size; drain writes one entry per idle → no unbounded PTY write burst.
- **No capability tokens needed** — this reuses the app's existing LAN/Origin trust boundary (same as every other control route); it does **not** widen it. No secrets logged; sanitize any queued text before logging via existing `sanitizeForLog` (`:162`).
- **Loopback drain source:** the drain trigger is the Stop hook, which is already loopback-gated at `/hook` (`:399`) — so the *timing* signal can't be forged remotely; only the *content* (Origin-gated) and it fires against the caller's own session.
---
## Effort & dependencies
- **Rough effort:** ~23 dev-days. Backend (types + config + manager methods + 3 routes + settle-timer wiring) ~1.5 d incl. tests; FE (`public/queue.ts` + tabs badge/affordance) ~0.51 d.
- **Depends on:** nothing new — builds entirely on shipped primitives (`writeInput`, `broadcast`, `handleHookEvent` idle branch, `requireAllowedOrigin`, `createRateLimiter`, `LiveSessionInfo`). No schema migrations, no new deps.
- **Coordination:** the `src/types.ts` edit is the only cross-cutting change — freeze it first (it touches `Config`, so every all-fields test `CFG` fixture must be updated in the same commit).
- **Unlocks (roadmap):** templated launches / auto-continue (queue a follow-up prompt on kickoff), issue-intake (external POST that enqueues), and any "when Claude finishes, do X" automation. The manage-page `queueLength` surface also feeds the multi-session workbench view.

View File

@@ -0,0 +1,131 @@
# Diff against a base branch (?base=<rev>)
Adds an optional `base` revision to the read-only git-diff side-channel so the viewer can compare a whole branch against `main` (or any commit-ish), not just the working tree / index. This lands the `FR-B1.9` deferral called out in `src/http/diff.ts:18-19`, using the exact mitigation named there: a `git rev-parse --verify` allow-list before any revision reaches the diff CLI. The diff **parsers and the render core stay untouched**; only a two-stage revision guard (backend), a reflected `base` field, and a toolbar picker (frontend) are added.
---
## Contract
### Route (unchanged path, one new optional query param)
`GET /projects/diff` (`src/server.ts:661-678`)
| Param | Type | Notes |
|---|---|---|
| `path` | string (required) | absolute git dir; validated by `isValidGitDir` (`src/server.ts:122-133`) — unchanged |
| `staged` | `0`\|`1` (optional) | current behavior; **ignored when `base` is present** |
| `base` | string (optional) | a commit-ish (branch/tag/sha/`HEAD~N`). When present → three-dot diff `git diff <base>... --`; untracked files are not listed |
Response is the existing `DiffResult` JSON, now with an optional reflected `base`:
- `200` structured `DiffResult` (with `base` echoed when supplied)
- `400 {error}` — missing `path`, **or** a `base` that fails the syntactic pre-check (flag injection / junk)
- `404 {error}` — path not a git dir (unchanged)
- Best-effort: a syntactically-valid but **unknown/unrelated** `base` (rev-parse miss, no merge-base) yields `200` with `files: []` — consistent with the module's "git failure → empty, never throw" house style (`src/http/diff.ts:14`, `:346`).
### Message / data types — `src/types.ts` (coordination edit)
Extend `DiffResult` (`src/types.ts:475-479`) with one **optional** field so the shape stays backward-compatible and the viewer's required-field validation is unaffected:
```
export interface DiffResult {
files: DiffFile[];
staged: boolean;
truncated: boolean;
base?: string; // NEW — echoed when the diff was against a base revision
}
```
No other shared type changes. `GetDiffOptions` lives in `src/http/diff.ts:260-263` (not `types.ts`) and gains `base?: string`.
### Env vars — `src/config.ts`
**None required.** rev-parse + diff reuse the existing `diffTimeoutMs` / `diffMaxBytes` bounds (`src/config.ts:347-362`). *(Optional kill-switch `DIFF_BASE_ENABLED` (default true) could be added mirroring `worktreeEnabled` at `src/config.ts:372` if a runtime disable is wanted — deferred, not needed for correctness.)*
### New/changed function signatures — `src/http/diff.ts`
```
export function isPlausibleRev(base: string): boolean // pure boundary check
async function resolveBaseRev(cwd, base, timeoutMs, maxBytes): Promise<string | null> // rev-parse --verify → canonical sha | null
export interface GetDiffOptions { staged: boolean; base?: string; cfg: Pick<Config,...> } // +base
export async function getDiff(repoPath, opts): Promise<DiffResult> // branches on opts.base
```
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add optional `base?: string` to `DiffResult` (`:475-479`). |
| `src/http/diff.ts` | Add exported pure `isPlausibleRev` (charset + no-`..` + no-leading-`-` + length≤250). Add `resolveBaseRev` (runs `git rev-parse --verify --quiet --end-of-options <base>^{commit}` via `runGit` `:289-309`; return trimmed `/^[0-9a-f]{7,64}$/` sha or `null`). Add `base?` to `GetDiffOptions` (`:260-263`). In `getDiff` (`:347-365`): if `opts.base` set → `resolved = resolveBaseRev(...)`; `null``{files:[],staged:false,truncated:false,base:opts.base}`; else run `git diff --no-color <resolved>... --` and `git diff --numstat <resolved>... --`, **skip** `listUntracked` (`:322-340`), set `staged:false`, echo `base:opts.base`. Working-tree path unchanged. |
| `src/server.ts` | Diff route (`:661-678`): read `base` (`typeof q==='string' && q!=='' ? q : undefined`); if present and `!isPlausibleRev(base)``400 {error:'invalid base revision'}`; else pass `base` into `getDiff(target,{staged,base,cfg})`. Import `isPlausibleRev` from `./http/diff.js`. Route stays no-Origin-guard (read-only, unchanged threat model). |
| `public/diff.ts` | `fetchDiff` (`:110-120`): change signature to `fetchDiff(repoPath, opts:{staged?:boolean; base?:string})`; build URL with `&base=<enc>` (omit `staged`) when `base` set, else `&staged=`. `normalizeDiffResult` (`:38-51`): pass through optional `base` (`typeof o['base']==='string' ? o['base'] : undefined`; keep other fields required). `MountDiffViewerOpts` (`:240-243`): add `bases?: string[]`. `mountDiffViewer` (`:253-347`): add a `<select>` "compare-base" control to the toolbar (`:265-272`) — first option `Working tree` (base=null), then one option per `bases[]`; track `base: string \| null`; when a base is chosen disable/grey the Working/Staged tabs and `loadDiff` calls `fetchDiff(repoPath,{base})`; back on "Working tree" restores `fetchDiff(repoPath,{staged})`. **Render core (`renderDiff`/`renderDiffFile`/`renderLine`/`renderHunk`) untouched.** |
| `public/projects.ts` | `buildDiffSection` (`:611-643`): add `bases: string[]` param; pass `{ bases, onClose }` into `mountDiffViewer` (`:625`). `renderProjectDetail` (`:672`, call site `:709`): derive `bases` = unique of `detail.worktrees.map(w=>w.branch)` (`WorktreeInfo.branch`, `src/types.ts:292`) `[detail.branch]`, filtered to defined strings; pass into `buildDiffSection(detail.path, diffRef, bases)`. This is the "reuse worktree/branch data" wiring. |
| `test/http/diff.test.ts` | unit `isPlausibleRev` + `getDiff` base integration (below). |
| `test/integration/worktree.test.ts` | route-level base tests (real `startServer`). |
| `test/diff.test.ts` | jsdom `fetchDiff`/`normalizeDiffResult`/picker tests. |
| `test/worktree-form.test.ts` | assert `bases` reach the `mountDiffViewer` mock. |
---
## TDD steps (ordered)
**1. Pure guard — `test/http/diff.test.ts` (node)** — add a `describe('isPlausibleRev')`:
- ✅ accepts `main`, `feature/x`, `HEAD~3`, `v1.2.0`, a 40-hex sha, `main^`, `HEAD@{1}`.
- ❌ rejects `''`, a 300-char string, `-rf`/`--output=x` (leading `-`), `a..b`, `x y` (whitespace), `` `id` `` / `$(x)` / `;` (metachars), `\x00`.
- Implement `isPlausibleRev` → GREEN. (Matches the existing pure-parser layer at `:34-266`.)
**2. `getDiff` with base — `test/http/diff.test.ts` (node, real repo)** — extend the `describe('getDiff (real git repo)')` block (`:274`). In a `beforeAll`-style setup, commit on `main`, then `git(repo,'checkout','-b','feature')`, commit a change:
- `getDiff(repo,{staged:false,base:'main',cfg:LIMITS})``result.base==='main'`, `result.staged===false`, the feature-only change present, **no `untracked` entries**, counts numstat-consistent (mirror `:292-302`).
- `base:'main'` while HEAD===main → `files:[]` (empty, no changes).
- `base:'no-such-branch'``{files:[], truncated:false}` (rev-parse miss → empty; assert never throws, like `:340-347`).
- `base:'-x'` never reaches here (route-guarded) but assert `getDiff` still returns empty (defense-in-depth) — optional.
- Implement `resolveBaseRev` + the base branch in `getDiff` → GREEN.
**3. Route — `test/integration/worktree.test.ts` (node, `startServer`)** — this file already covers `GET /projects/diff` (header comment `:2-11`); add, using its `itGit` + temp-repo + two-branch setup:
- `GET /projects/diff?path=<repo>&base=feature``200`, `DiffResult` with `base:'feature'`, verbatim content.
- `GET /projects/diff?path=<repo>&base=-rf``400`.
- `GET /projects/diff?path=<repo>&base=ghost-branch``200` with `files:[]`.
- Wire `base` parse + `isPlausibleRev` 400 into the route → GREEN.
**4. Frontend fetch/normalize — `test/diff.test.ts` (jsdom)** — the file mocks `fetch`; add:
- `fetchDiff('/repo',{base:'main'})` builds `/projects/diff?path=%2Frepo&base=main` (no `staged=`); `fetchDiff('/repo',{staged:true})` builds the current `&staged=true` URL (update the existing signature-based tests).
- `normalizeDiffResult({files:[],staged:false,truncated:false,base:'main'})``.base==='main'`; a payload without `base``.base===undefined` and still valid.
- Implement `fetchDiff` opts + `normalizeDiffResult` pass-through → GREEN.
**5. Base picker — `test/diff.test.ts` (jsdom)** — extend `describe('mountDiffViewer')` (`:353`):
- `mountDiffViewer(container,'/repo',{bases:['main','dev']})` renders a `<select>` with options `Working tree` + `main` + `dev`.
- Selecting `main` (dispatch `change`) triggers a fetch whose URL contains `base=main` and disables the Working/Staged tabs; selecting `Working tree` restores a `staged`-mode fetch.
- Empty/absent `bases` → no `<select>` (backward-compatible with existing tests that call `mountDiffViewer(container,'/repo',{})`).
- Implement toolbar select + state → GREEN.
**6. Wiring — `test/worktree-form.test.ts` (jsdom)** — it already mocks `mountDiffViewer` (`:31`) and tests `renderProjectDetail`/`buildDiffSection` (`:402-421`). Add: give a `ProjectDetail` with `worktrees:[{branch:'main',...},{branch:'feat',...}]`, click "View Diff", assert the `mockMountDiffViewer` was called with `bases` containing `main` and `feat`. Implement `buildDiffSection` + `renderProjectDetail` derivation → GREEN.
**7. Refactor / coverage**`npm test`; confirm the 80% gate holds (every new branch — `isPlausibleRev` both arms, `resolveBaseRev` hit/miss, `getDiff` base/no-base, route 400/200, picker on/off — is exercised above).
---
## Edge cases & failure modes
- **`base=''`** → treated as absent (route coerces to `undefined`) → normal working-tree diff.
- **`base` + `staged=1` both set** → `base` wins; staged silently ignored; result `staged:false`. (Documented; the picker disables the staged tab in base mode so the UI can't send both.)
- **Flag injection** (`base=-rf`, `--output=/etc/passwd`) → rejected by `isPlausibleRev` (leading `-`) → `400`; even if it slipped through, `--end-of-options` in rev-parse and the trailing `--` in `git diff` neutralize it.
- **Range injection** (`base=a..b`, `base=a...b`) → `isPlausibleRev` rejects `..`; we construct the `...` ourselves from a single resolved sha.
- **Unknown ref** (typo, deleted branch) → rev-parse `--verify` miss → `resolveBaseRev` returns `null` → empty `DiffResult` (no crash). Rare in practice since the picker only offers real worktree branches.
- **Unrelated histories** (no merge-base for `<base>...HEAD`) → `git diff` errors → `runGit` returns empty (`:302-308`) → empty result.
- **`base` peels to a tree/tag-of-tree, not a commit** → `^{commit}` peel fails → `null` → empty.
- **Detached HEAD in the repo** → `HEAD` still resolves; three-dot works.
- **Huge branch diff** → existing `diffMaxFiles`/`diffMaxBytes`/timeout truncation applies unchanged (`:360-364`).
- **Rename/binary/new/deleted across the base range** → handled by the untouched `parseUnifiedDiff`/`parseNumstat` (numstat is authoritative for counts, `:187-205`).
- **Old git without `--end-of-options`** (pre-2.24) → not a concern in 2026, but since `isPlausibleRev` already blocks leading `-`, the flag can be dropped without loss if a legacy git is hit.
- **jsdom picker with `bases:[]` or omitted** → no select rendered; existing `mountDiffViewer(container,'/repo',{})` tests keep passing.
## Security
- **Revision allow-list (the core mitigation, `src/http/diff.ts:18-19`)** — two stages, both before the diff CLI: (1) `isPlausibleRev` — a pure boundary check (`/^[A-Za-z0-9][A-Za-z0-9._/@^~{}-]{0,249}$/`, reject `..`) rejecting flag-injection/junk fast with a `400`; (2) `git rev-parse --verify --quiet --end-of-options <base>^{commit}` — git itself is the authoritative allow-list, and its output (a canonical 40/64-hex sha) is what's passed to `git diff`, fully decoupling the raw user string from the diff invocation.
- **No shell** — all git calls stay `execFile('git',[...])` (`:296`), args as an array; trailing `--` terminates options on every diff command (`:352-353`), matching the file's SEC note (`:11-12`).
- **Read-only** — rev-parse and `git diff` are read-only; `base` introduces **no** write path, so the route keeps its no-Origin-guard status (same threat model as `/projects`, `:660`). No new state-changing surface → no `requireAllowedOrigin` / CSRF change needed.
- **Path containment** — unchanged: `isValidGitDir` three-prong (`:122-133`) still gates `path`; `base` cannot escape the repo (rev-parse resolves inside `cwd`).
- **DoS bounds** — the extra rev-parse spawn reuses `diffTimeoutMs`/`diffMaxBytes` via `runGit` (`:289-301`); no unbounded work added.
- **Frontend XSS** — `base` is echoed and rendered only via `textContent`/`<option>.textContent`; the SEC-H4 "zero innerHTML" invariant of `public/diff.ts` (`:8-9`) is preserved (render core untouched).
- **Rate-limit** — parity with the existing diff route (no per-route limiter today); base adds one bounded read-only spawn per request, no new amplification. If the route is later rate-limited, this feature needs no change.
## Effort & dependencies
- **Effort:** ~1.52 days. Backend guard + `getDiff` branch (~0.5d incl. tests), route wiring (~0.25d), FE picker + `projects.ts` wiring (~0.75d incl. jsdom tests), polish/coverage (~0.25d).
- **Depends on:** the shipped B1 diff stack — `src/http/diff.ts`, `public/diff.ts`, the `/projects/diff` route, and B3 worktree/branch data in `ProjectDetail.worktrees` (`src/types.ts:290-312`) which the picker reuses. No new features required.
- **Unlocks / adjacent:** W13 "Stage / commit / push from the diff viewer" (a base-vs-branch view is the natural surface for review-before-push) and W10 "PR + CI status chip" (comparing a feature branch against its PR base). Keeping the render core and parsers unchanged means those build on the same `DiffResult` without churn.

181
docs/plans/w3-pr-ci-chip.md Normal file
View File

@@ -0,0 +1,181 @@
# PR + CI/checks status chip via gh
A per-project chip in the project-detail view that shows, for the repo's current branch: **PR state** (open / draft / merged / closed / none), **N checks passing** (from `statusCheckRollup`), and **mergeable** (clean / conflicting). It is a read-only, out-of-band side-channel — exactly like `getDiff` (`src/http/diff.ts`): `execFile('gh', …)` (no shell), timeout + `maxBuffer` bound, parses `gh`'s `--json` output, and **capability-degrades** (chip explains itself) when `gh` is missing, unauthenticated, or the branch has no PR. Cached at module scope with a short TTL (reuses `cfg.projectScanTtlMs`) so opening/refreshing a project detail doesn't hammer the GitHub API.
Grounding: `buildProjectDetail` (`src/http/projects.ts:420`) surfaces branch/dirty/worktrees but nothing PR. `getDiff`/`runGit` (`src/http/diff.ts:289`) is the runner+degrade pattern to mirror. `isValidGitDir` (`src/server.ts:123`) + the `/projects/diff` route (`src/server.ts:661`) are the exact route mirror. The FE detail header is `renderProjectDetail` (`public/projects.ts:672`, header at lines 696-706); `buildDiffSection` (`public/projects.ts:612`) and `public/diff.ts` `fetchDiff`/`normalizeDiffResult` (lines 110/38) are the FE fetch+degrade+textContent pattern.
---
## Contract
### New route
`GET /projects/pr?path=<abs-repo-dir>` — read-only, **no** Origin guard (same threat model as `/projects` and `/projects/diff`; see `src/server.ts:660` comment).
- `400 {error}``path` missing/empty (mirror `src/server.ts:662-666`).
- `404 {error:'project not found'}``!isValidGitDir(target)` (mirror `src/server.ts:667-670`, SEC-H7 three-prong).
- `200 PrStatus`**always** on a valid git dir, including all degrade cases (the availability lives in the body, not the HTTP status — so the FE renders one chip regardless). `500 {error}` only on an unexpected throw (mirror `src/server.ts:674-677`).
### New message type — `src/types.ts` (coordination edit, next to `DiffResult` at line 475)
```ts
/* ── W3 PR + CI status chip (gh) ── */
/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
export type PrAvailability =
| 'ok' // a PR exists for the current branch; fields below are populated
| 'no-pr' // gh works but the branch has no PR (or no remote/default repo)
| 'not-installed' // `gh` binary not found on PATH (ENOENT)
| 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
| 'disabled' // GH_ENABLED=0 — feature off, never spawns gh
| 'error'; // gh spawned but failed for another reason (timeout, etc.)
/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
export interface PrCheckSummary {
total: number;
passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
}
/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
export interface PrStatus {
availability: PrAvailability;
number?: number;
title?: string;
url?: string;
state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
isDraft?: boolean;
mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
headRefName?: string;
baseRefName?: string;
checks?: PrCheckSummary;
}
```
### `gh` invocation (single spawn — KISS)
One command; `statusCheckRollup` already carries per-check state, so no second `gh pr checks` spawn:
```
gh pr view --json number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup
```
Run with `cwd = repoPath`. gh resolves the PR from the current branch. `statusCheckRollup` items are a mix of `{__typename:'CheckRun', status, conclusion}` and `{__typename:'StatusContext', state}` — the pure parser handles both.
### New env vars — `src/config.ts` + `Config` in `src/types.ts` (coordination edit)
| Env var | Default | Purpose |
|---|---|---|
| `GH_ENABLED` | `true` (`parseBool`) | Feature flag. `false` → route returns `{availability:'disabled'}`, never spawns gh. |
| `GH_TIMEOUT_MS` | `8000` (`parseNonNegativeInt`) | Hard-kill timeout for the gh spawn. Larger than `diffTimeoutMs` (2 s) because gh hits the network. |
Cache TTL **reuses** `cfg.projectScanTtlMs` (`src/config.ts:311`, default 10 000 ms) — no new TTL var. Add the two fields to the assembled object in `loadConfig` (`src/config.ts:390-438`) and to the `Config` interface.
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `PrAvailability`, `PrCheckSummary`, `PrStatus` near `DiffResult` (line 475). Add `ghEnabled: boolean` + `ghTimeoutMs: number` to the `Config` interface (find `Config` — used by `loadConfig`). |
| `src/config.ts` | Add `const DEFAULT_GH_TIMEOUT_MS = 8000` near line 65; parse `GH_ENABLED` via `parseBool(env['GH_ENABLED'], true)` and `GH_TIMEOUT_MS` via `parseNonNegativeInt(...)`; add both to the frozen object (lines 413-438). |
| `src/http/gh.ts` | **New file** (~180 lines), mirrors `diff.ts` structure: a `runGh` runner (`execFileAsync('gh', args, {cwd, timeout, maxBuffer})` that captures `stdout`/`stderr`/`code`/spawn-ENOENT), a **pure** `parsePrView(json): PrStatus`-core + `summarizeChecks(rollup): PrCheckSummary`, a `classifyGhFailure(exec): PrAvailability`, module-scope short-TTL cache mirroring `discoverCache` (`projects.ts:239-317`) with in-flight dedupe, `getPrStatus(repoPath, cfg): Promise<PrStatus>`, and a `_clearPrCache()` test hook (mirror `_clearProjectCache`, `projects.ts:314`). |
| `src/server.ts` | Add `import { getPrStatus } from './http/gh.js'` (next to line 41). Add `GET /projects/pr` route immediately after `/projects/diff` (after line 678), copying the `path`-missing → 400 and `!isValidGitDir` → 404 guards, then `res.json(await getPrStatus(target, cfg))` in a try/catch → 500 (mirror lines 671-677). |
| `public/gh-chip.ts` | **New file** (~120 lines), render-only, mirrors `public/diff.ts`: `normalizePrStatus(raw): PrStatus \| null`, `fetchPrStatus(repoPath): Promise<PrStatus \| null>` (mirror `fetchDiff`, `diff.ts:110`), `chipText(status): {label, cls, title}` (pure, unit-tested), `renderPrChip(status): HTMLElement` (all text via **`textContent`**, zero `innerHTML` — SEC-H4), `mountPrChip(container, repoPath): {destroy()}` that shows a loading placeholder then swaps in the resolved chip. |
| `public/projects.ts` | Add `import { mountPrChip } from './gh-chip.js'` (near line 27). In `renderProjectDetail`, after the dirty indicator (line 704) inside the `if (detail.isGit)` guard, append the chip host and mount it; track the handle in a `prRef` and `destroy()` it in the `back` click handler (lines 683-688) alongside `diffRef.h?.destroy()`. |
| `public/styles.css` (or the existing project-detail CSS file) | Add `.proj-pr-chip` + state modifier classes (`.proj-pr-open/.draft/.merged/.closed/.none/.unavailable`, `.proj-pr-checks-ok/.fail/.pending`, `.proj-pr-conflict`). Reuse the existing `.proj-branch` chip look (line 699) as the base. |
| `test/http/gh.test.ts` | **New** (node) — pure parsers + `getPrStatus` classification. |
| `test/integration/pr-status.test.ts` | **New** (node) — real `startServer`, `gh` stubbed via a PATH shim. |
| `test/gh-chip.test.ts` | **New** (jsdom) — `normalizePrStatus`/`chipText`/`renderPrChip`/`mountPrChip`. |
---
## TDD steps
Ordered; each "write test → run RED → implement → GREEN". Backend-pure first (cheap, deterministic), then route, then FE. Keeps the 80 % gate because the pure parser + classifier are the bulk of the logic and are fully covered without spawning gh.
**Backend — `test/http/gh.test.ts`** (node; mirror `test/http/diff.test.ts:1-33`). Import from `../../src/http/gh.js`.
1. `summarizeChecks` — canned `statusCheckRollup` arrays:
- CheckRun `{status:'COMPLETED',conclusion:'SUCCESS'}` → passing++. Implement `summarizeChecks`.
- CheckRun `conclusion:'FAILURE'` → failing++; `TIMED_OUT`/`CANCELLED`/`ACTION_REQUIRED` → failing.
- CheckRun `status:'IN_PROGRESS'`/`'QUEUED'` (null conclusion) → pending.
- StatusContext `{state:'SUCCESS'}` → passing; `'PENDING'` → pending; `'FAILURE'/'ERROR'` → failing.
- `NEUTRAL`/`SKIPPED` → passing (don't block). Empty/`undefined` rollup → all-zero. Unknown shape → counted in `total` only, treated as pending. Assert `total === passing+failing+pending`.
2. `parsePrView` — feed a full canned JSON string:
- Valid PR JSON → `availability:'ok'`, lower-cased `state`/`mergeable`, `number`/`title`/`url`/`isDraft`/`headRefName`/`baseRefName` mapped, `checks` from `summarizeChecks`. Implement `parsePrView` (never throws — `try/JSON.parse`; malformed → `{availability:'error'}`, mirroring `diff.ts` "never throws" house style).
- `isDraft:true` still `availability:'ok'` (FE decides the "draft" label); `mergeable:'UNKNOWN'``'unknown'`.
- Malformed / non-object JSON → `{availability:'error'}`.
- **Security assert**: a PR `title` containing `<script>alert(1)</script>` survives verbatim in `PrStatus.title` (proves no parsing-side mangling; FE renders it inert).
3. `classifyGhFailure` — given synthetic exec results:
- spawn ENOENT (`code:'ENOENT'`) → `'not-installed'`.
- stderr containing `gh auth login` / `not logged` / `authentication` / `HTTP 401``'unauthenticated'`.
- stderr containing `no pull requests found` / `no default remote` / `no git remote``'no-pr'`.
- other non-zero exit → `'error'`. Implement `classifyGhFailure` (regex on lower-cased stderr).
4. `getPrStatus` cache/dedupe — inject a fake runner (or spy) so no real gh spawns:
- `ghEnabled:false` in cfg → resolves `{availability:'disabled'}` **without** invoking the runner.
- Two rapid calls for the same path share one in-flight run (assert runner called once); after `_clearPrCache()`, it runs again. Mirror `projects.ts:289-311`. Implement the module cache + `_clearPrCache`.
- Cache key includes the current branch (cheap read of `.git/HEAD` like `readBranch`, `projects.ts:88`) so a branch switch busts the cache before TTL. Test: same path, different HEAD branch → runner re-invoked.
> To keep `getPrStatus` unit-testable without gh, factor the spawn into an injectable `runGh` (default real, overridable in tests) — same seam idea as `getDiff`'s `runGit`. `parsePrView`/`summarizeChecks`/`classifyGhFailure` stay pure and exported.
**Route — `test/integration/pr-status.test.ts`** (node; mirror `test/integration/projects-endpoint.test.ts:1-55`). Use `getFreePort` + a temp dir with a fake `.git` repo (reuse `makeFakeGitRepo` shape). Stub gh with a **PATH shim**: write an executable script named `gh` into a temp `bin/` that echoes canned JSON (or exits 1 with a canned stderr), then set `process.env.PATH = binDir + ':' + process.env.PATH` before `startServer` (execFile resolves `gh` via PATH). Restore PATH + `_clearPrCache()` in `afterEach`.
5. Missing `path`**400**. Implement route guard 1.
6. Non-git dir path → **404** (`isValidGitDir` fails). Implement guard 2.
7. gh shim emits valid PR JSON → **200** with `availability:'ok'`, correct `checks`. Wire `res.json(await getPrStatus(...))`.
8. gh shim exits 1 with `no pull requests found` on stderr → **200 `{availability:'no-pr'}`**.
9. `GH_ENABLED='0'` env → **200 `{availability:'disabled'}`**, and (assert via a shim that writes a marker file) gh is never spawned.
**Frontend — `test/gh-chip.test.ts`** (jsdom; mirror `test/diff.test.ts:1-14`, `// @vitest-environment jsdom`, dynamic `await import('../public/gh-chip.js')`, `vi.stubGlobal('fetch', …)`).
10. `normalizePrStatus` — valid object round-trips; non-object / bad `availability``null` (mirror `normalizeDiffResult`, `diff.ts:38`). Implement.
11. `chipText(status)` — pure map: `ok`+open → `"PR #12 ✓ 5/5"`; failing checks → `"PR #12 ✕ 3/5"`; `mergeable:'conflicting'` adds a `⚠ conflicts` marker/class; `no-pr``"No PR"`; `not-installed``"gh not installed"` + `title` link to cli.github.com; `unauthenticated``"gh auth login"`; `disabled` → chip hidden (returns null/`display:none`). Implement.
12. `renderPrChip`**SEC-H4 assert**: a `title` of `<img src=x onerror=...>` appears as literal text (`el.textContent` contains it, `el.querySelector('img')` is null). Zero `innerHTML`.
13. `mountPrChip``fetch` stubbed to resolve `ok` JSON → placeholder replaced by the chip; `fetch` rejects → degrades to an `error` chip (no throw); `destroy()` removes the node. Mirror `mountDiffViewer` (`diff.ts:253-`).
**Frontend wiring — extend `test/projects.test.ts`** (jsdom; `renderProjectDetail` is already exported/tested there).
14. `renderProjectDetail` with `detail.isGit:true` → a `.proj-pr-chip` host is present in the header; with `isGit:false` → absent. (Mock `gh-chip`'s `mountPrChip` via `vi.mock` so the DOM assertion doesn't depend on fetch.) Implement the `renderProjectDetail` edit + `prRef.destroy()` in the back handler.
Run `npm test` after each GREEN. The pure backend parser tests (steps 1-3) carry most of the coverage weight for `gh.ts`; FE steps 10-13 cover `gh-chip.ts`.
---
## Edge cases & failure modes
- **`gh` not installed** → spawn ENOENT → `'not-installed'`. Chip shows "gh not installed" (never a 500, never a stack trace).
- **`gh` present, not authenticated** → stderr auth pattern → `'unauthenticated'` → "gh auth login".
- **No PR for branch / no remote / detached HEAD** → `'no-pr'` → "No PR". (Detached HEAD: `.git/HEAD` isn't `ref:` → cache-key branch is `null`; gh itself errors → `no-pr`.)
- **PR exists but checks not started / all pending** → `checks.total>0, passing=0, pending=total` → "⧗ 0/N".
- **`mergeable:'UNKNOWN'`** (GitHub computes mergeability async right after a push) → `'unknown'` → neutral marker, not a red "conflict". Only `'conflicting'` shows the ⚠.
- **Draft PR** → `isDraft:true` → "Draft" styling; still `availability:'ok'`.
- **Merged/closed PR still on the branch** → `state:'merged'/'closed'` badge (gh returns the most recent PR).
- **gh timeout** (network hang) → `execFileAsync` kills at `ghTimeoutMs``'error'` → generic "PR status unavailable". Bounded, never hangs the request.
- **Huge `statusCheckRollup`** (100s of checks / monorepo) → bounded by `maxBuffer` (reuse `diffMaxBytes`); overflow → `'error'` (don't try to parse a truncated JSON). Counts are aggregate so the chip stays tiny.
- **Malformed `--json` output** (gh version drift) → `parsePrView` `JSON.parse` throws → caught → `'error'`.
- **Branch switch within TTL** → cache key includes HEAD branch, so it busts immediately rather than showing the previous branch's PR for up to 10 s.
- **FE fetch/network error** → `fetchPrStatus` returns `null` → chip renders an `'error'` state, never throws (mirror `fetchDiff`, `diff.ts:117`).
---
## Security
- **No shell**: `execFile('gh', [fixed argv])` — identical guarantee to `runGit` (`diff.ts:296`, SEC-M9). The **only** user-influenced input reaching gh is `cwd`, which is the already-validated `repoPath`. No untrusted string is ever placed in argv (gh derives the PR from the branch; we never pass a branch/base/rev). This sidesteps the `?base=` deferral rationale in `diff.ts:18-20`.
- **Path containment**: route calls `isValidGitDir(target)` (`server.ts:123`) before spawning — absolute + is-dir + has `.git` (SEC-H7). Path traversal / arbitrary-cwd is blocked exactly as `/projects/diff`.
- **DoS bounds**: `timeout: ghTimeoutMs` (hard kill) + `maxBuffer: cfg.diffMaxBytes` bound a slow/huge gh. Module-scope TTL cache + in-flight dedupe cap outbound GitHub-API calls to ≈1 per repo per `projectScanTtlMs` even under rapid detail-view refreshes (the panel auto-refreshes every 5 s, `projects.ts:33`).
- **Network egress note**: unlike every other side-channel (all local), gh talks to GitHub's API using the host's existing `gh`/`GH_TOKEN` credential. The endpoint **never accepts or forwards a token** — it only triggers gh's own auth. Document this in the route comment and in TECH_DOC §7 (this is the first feature to make an outbound call on behalf of a LAN client; the `GH_ENABLED=0` kill-switch lets a cautious operator disable it entirely).
- **No secret leakage**: never log gh **stdout** (may contain private PR titles) or the token. If logging a failure, log only `availability` + `sanitizeForLog(stderr.slice(0,200))` (reuse `server.ts:162`) — never raw stderr, mirroring the worktree audit line (`server.ts:714`) and SEC-M10 "never raw git stderr".
- **Origin/CSRF**: GET is read-only and non-mutating → no Origin guard, consistent with `/projects` and `/projects/diff` (`server.ts:660`). It spawns a subprocess but performs no state change, so CSWH/CSRF risk is limited to triggering a cached, rate-bounded read.
- **XSS**: PR `title` is attacker-controllable (anyone who can open a PR on a repo the host has access to). It is carried verbatim server-side and rendered **only via `textContent`** in `gh-chip.ts` (SEC-H4, same discipline as `diff.ts` line-rendering). Explicit jsdom test (step 12) asserts no element injection.
- **Rate-limit**: no per-IP limiter needed (read-only, same as `/projects/diff`); the TTL cache is the effective throttle. If desired later, the `createRateLimiter` helper (`server.ts:109`) is available.
---
## Effort & dependencies
- **Effort**: ~1.52 days. Backend `gh.ts` + route ≈ 0.75 day (the runner/cache pattern is a near-copy of `diff.ts`/`projects.ts`; the pure `parsePrView`/`summarizeChecks` classifier is the real work). FE `gh-chip.ts` + wiring + CSS ≈ 0.5 day. Tests (3 files) ≈ 0.5 day, with the PATH-shim integration harness the only novel piece.
- **Depends on**: nothing hard-blocking — `Config`/`src/types.ts`/`config.ts` coordination edits and `isValidGitDir` all exist today. Requires `gh` on the host for the live path, but the feature is designed to degrade cleanly without it (so it ships regardless).
- **Unlocks**: the **W3 "Quick wins" chip** (task #11 — sync/ahead-behind chip, recent commits) can reuse the same `gh.ts`/`gh-chip.ts` side-channel + short-TTL-cache scaffold (e.g. `gh pr status`, `git rev-list --count @{u}...HEAD`). The PATH-shim gh-integration harness is reusable by any future gh-backed feature.
- **Interacts with (no conflict)**: **W3 `?base=` diff** (task #9) touches `diff.ts`/`/projects/diff` only; this feature is an independent file (`gh.ts`) and route (`/projects/pr`). Both add a chip/section to the same `renderProjectDetail` header — coordinate the header layout (place the PR chip after the branch chip, before or beside the diff toggle) but they do not edit the same functions.

174
docs/plans/w3-quick-wins.md Normal file
View File

@@ -0,0 +1,174 @@
# Quick wins: sync chip + cost guard + reconnect digest + recent commits
Four small, independent features that ride existing seams. None touches the byte-shuttle WS stream; all new HTTP routes are side-channel and follow the established `/projects/*` / `/hook/*` patterns. Read-only routes get no Origin guard (same threat model as `/projects``src/server.ts:281`); the cost guard rides the already-injected `NotifyService` DI seam and the `stuckNotified` one-shot-latch pattern (`src/session/manager.ts:271-287`).
Shared coordination edit up front: **`src/types.ts`** gains `ProjectInfo.ahead/behind/lastCommitMs` (§a), `NotifyClass` `'budget'` + `Session.budgetNotified` + `Config.costBudgetUsd` + `UiConfig.costBudgetUsd` (§b), `DigestResult`/`DigestSession` (§c), `CommitLogEntry`/`GitLogResult` (§d). Do all type edits in one commit so the four sub-features can then proceed in parallel.
---
## Contract
### (a) Sync chip — no new route
Folded into the existing `GET /projects` payload. `ProjectInfo` gains three optional fields, populated in the cached per-repo metadata pass:
```ts
// src/types.ts — extend ProjectInfo (currently line 279-287)
export interface ProjectInfo {
name: string
path: string
isGit: boolean
branch?: string
dirty?: boolean
lastActiveMs?: number
ahead?: number // commits on HEAD not on @{u} (git rev-list, right count)
behind?: number // commits on @{u} not on HEAD (git rev-list, left count)
lastCommitMs?: number // git log -1 --format=%ct * 1000 (HEAD commit time)
sessions: ProjectSessionRef[]
}
```
`@{u}` with no upstream / non-git → all three `undefined` (best-effort, never throws). No new env var: gated by the existing `projectDirtyCheck` (which already means "spend git subprocess time per repo").
### (b) Cost budget guard + push alert
- **New env** `COST_BUDGET_USD` (dollars, float ≥ 0; default `0` = disabled) → `Config.costBudgetUsd: number` (`src/types.ts` Config, alongside B2 `statuslineTtlMs` at line 65).
- **New `NotifyClass` member** `'budget'` (`src/types.ts:376`): `'needs-input' | 'done' | 'stuck' | 'budget'`.
- **New `Session` field** `budgetNotified: boolean` (`src/types.ts` Session, next to `stuckNotified` at line 227-228) — one-shot latch, **never re-armed** (cost is monotonic).
- **`GET /config/ui`** payload gains `costBudgetUsd?: number` so the FE can derive warn-styling client-side:
```ts
export interface UiConfig { allowAutoMode: boolean; costBudgetUsd?: number }
```
- **No new ServerMessage variant.** The "warning broadcast" is the *existing* `{type:'telemetry', telemetry}` frame (already broadcast on every statusLine, `manager.ts:260`); the warn is derived on the client (`costUsd >= costBudgetUsd`). The distinct new server action on threshold crossing is a single `notifyService.notify(session, 'budget')` push. (Rationale: keeps the frozen `ServerMessage` contract in `types.ts:109-120` untouched — KISS/YAGNI. A dedicated frame was considered and rejected: cost overage is not a `ClaudeStatus`.)
- `renderTelemetryGauge` (`public/preview-grid.ts:197`) gains a 4th param `costBudgetUsd?: number`; the cost chip (line 224-227) gets class `tg-cost-warn` when `telemetry.costUsd >= budget`, mirroring the ctx>80% path at line 219.
### (c) While-you-were-away reconnect digest
**New read-only route** `GET /digest?since=<epochMs>` (no Origin guard; same as `/live-sessions`). Aggregates `manager.list()`:
```ts
export interface DigestSession {
id: string
title?: string // last cwd segment
status: ClaudeStatus
costUsd?: number // telemetry.costUsd
lastOutputAt?: number
finished: boolean // status==='idle' && lastOutputAt > since
needsInput: boolean // status==='waiting'
stuck: boolean // status==='stuck'
}
export interface DigestResult {
since: number
generatedAt: number
total: number
finished: number
needsInput: number
stuck: number
working: number
totalCostUsd: number
sessions: DigestSession[]
}
```
Response: `200` + `DigestResult` (empty aggregate when no sessions). `since` clamped to a finite non-negative number (bad/absent → `0`, i.e. "everything is new").
### (d) Recent-commits log per project
**New read-only route** `GET /projects/log?path=<abs>&n=<int>` (no Origin guard; guarded by `isValidGitDir`, `src/server.ts:123`, exactly like `/projects/diff` at line 661-678).
```ts
export interface CommitLogEntry { hash: string; at: number; subject: string } // at = %ct*1000
export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean }
```
- `path` missing/empty → `400`; not a valid git dir → `404` (three-prong `isValidGitDir`); git failure → `500`.
- `n` parsed to int, clamped to `[1, GIT_LOG_MAX]` (const `50`; default `20`).
- Git command (NUL-record, US-field delimited so subjects with tabs/newlines can't corrupt parsing):
```
git log --no-color -z -n <n> --format=%h%x1f%ct%x1f%s (cwd: repoPath, timeout, maxBuffer)
```
---
## Files to change
| Path | Change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `ProjectInfo.ahead/behind/lastCommitMs`; `NotifyClass` `'budget'`; `Session.budgetNotified`; `Config.costBudgetUsd`; `UiConfig.costBudgetUsd`; new `DigestResult`/`DigestSession`, `CommitLogEntry`/`GitLogResult`. |
| `src/config.ts` | Add `parseNonNegativeFloat(raw,label,fallback)` helper; parse `COST_BUDGET_USD`→`costBudgetUsd` (near B2 block line 365-369); add `costBudgetUsd` to the frozen object (spread block line 415-437). |
| `src/http/projects.ts` | Add `readSync(repoPath)` helper (2 execFile calls); extend `MakeProjectArgs` (line 121) + `makeProject` (line 129) with `ahead/behind/lastCommitMs`; call `readSync` in `runDiscovery`'s per-repo `mapWithConcurrency` (line 276-280), gated by `cfg.projectDirtyCheck`. |
| `src/http/git-log.ts` | **New.** `parseGitLog(stdout,max)` (pure) + `getGitLog(repoPath,{n,timeoutMs})` (async, execFile, no shell). Modelled on `src/http/diff.ts` / `readDirty` (`projects.ts:98`). |
| `src/http/digest.ts` | **New.** `buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult` — pure, injected list (mirrors `buildProjects` injection, `projects.ts:387`). |
| `src/session/manager.ts` | In `handleStatusLine` (line 256): after storing/broadcasting telemetry, run the budget-latch check → `notifyService?.notify(session,'budget')`. |
| `src/session/session.ts` | Init `budgetNotified: false` in `createSession` object literal (~line 138). **Do not** re-arm (leave line 150 as-is). |
| `src/server.ts` | Add `GET /projects/log` (after `/projects/diff`, line 678); add `GET /digest` (after `/live-sessions`, line 279); extend `GET /config/ui` (line 728-731) with `costBudgetUsd`. Import `getGitLog`, `buildDigest`. |
| `public/preview-grid.ts` | `renderTelemetryGauge` gains `costBudgetUsd?` param; add `tg-cost-warn` class on the cost chip (line 224-227). |
| `public/projects.ts` | `normalizeProject` (line 236): pass through numeric `ahead/behind/lastCommitMs`. `makeProjectCard` (line 454): add sync chip after branch chip. `renderProjectDetail` (line 709 area, git repos only): mount recent-commits section. |
| `public/git-log.ts` | **New.** `mountGitLog(container, repoPath)` — fetch `GET /projects/log`, render inert rows via `textContent` only. |
| `public/digest.ts` | **New.** `mountDigest(host)` — read `localStorage` last-seen, fetch `GET /digest?since=`, render dismissible banner, update last-seen. |
| `public/tabs.ts` | In `loadUiConfig` (line 248-261): also read `costBudgetUsd`; store on the instance; pass to `renderTelemetryGauge` call (line 1391). |
| `public/main.ts` | `mountDigest(...)` in the toolbar/app wiring block (~line 51-119). |
| `public/style.css` | Add `.tg-cost-warn`, `.proj-sync`, `.proj-commitlog` rows, `.wya-banner` (mirror `.tg-ctx-warn`, `.proj-branch`). |
---
## TDD steps (ordered)
Repo style: backend unit tests are **node** env (default), FE tests declare `// @vitest-environment jsdom` at the top (`test/projects-panel.test.ts:1`) and mock `@xterm/xterm`; route tests spin a real server on a free port (`test/integration/projects-endpoint.test.ts`). `test/manager.test.ts` uses a mock `NotifyService` recording `notify(session,cls,token)` (line 100-107) and a `parseSent(ws)` helper.
**0. Types (RED→GREEN, compile-only)**
- [ ] Edit `src/types.ts` with all shapes above. `npx tsc --noEmit` fails where callers/mocks lack new required fields → gives the worklist. `Session.budgetNotified` is required → `test/manager.test.ts` + `src/session/session.ts` must init it.
**(b) Cost guard — highest security value, do first**
- [ ] `test/config.test.ts`: add defaults block — `loadConfig({}).costBudgetUsd === 0`; override `COST_BUDGET_USD:'5.50'`→`5.5`; `throw` for `'abc'` and `'-1'` (mirror line 156-181). Implement `parseNonNegativeFloat` + wire in `src/config.ts`.
- [ ] `test/manager.test.ts` (extend `describe('handleStatusLine')` line 803): with `cfg.costBudgetUsd=1`, feed telemetry `costUsd=0.5` → `notify` **not** called, `session.budgetNotified===false`; feed `costUsd=1.2` → `notify` called once with `(s,'budget')`, latch `true`; feed `costUsd=2` again → **not** called again. With `costBudgetUsd=0` → never called. Implement the latch in `manager.handleStatusLine`.
- [ ] `test/session.test.ts`: assert `createSession(...).budgetNotified === false`. Implement init in `session.ts`.
- [ ] `test/preview-grid.test.ts` (jsdom): `renderTelemetryGauge(c, {costUsd:6,at:now}, ttl, 5)` → cost chip has class `tg-cost-warn`; with budget `0`/`undefined` or `costUsd<budget` → no warn class. Implement param.
- [ ] Route: extend `test/integration` (or `test/http`) — `GET /config/ui` returns `costBudgetUsd`. Implement in `server.ts:728`.
**(d) Recent commits**
- [ ] `test/http/git-log.test.ts` (new): `parseGitLog` — NUL-record + US-field splitting; empty stdout→`[]`; malformed record (missing field) skipped; subject truncated at cap; `truncated` flag when records===max. (Pure, no spawn.)
- [ ] `test/http/git-log.test.ts`: `getGitLog` against a real temp repo made with `git init` + 3 commits (pattern from `test/http/diff.test.ts` / worktrees test) → 3 entries newest-first, `n=2`→2 + `truncated:true`.
- [ ] `test/integration/projects-log-endpoint.test.ts` (new, model on `projects-endpoint.test.ts`): `GET /projects/log?path=<repo>` → 200 array; missing `path`→400; non-git temp dir→404; `?n=999`→clamped. Implement route in `server.ts`.
- [ ] FE `test/git-log.test.ts` (jsdom): `mountGitLog` renders rows via `textContent`; a commit subject containing `<img onerror>` appears verbatim (no HTML injection); fetch failure → empty/error inert text. Implement `public/git-log.ts` + detail-section wiring in `public/projects.ts`.
**(a) Sync chip**
- [ ] `test/projects.test.ts` (extend, node): real temp repo (uses `execFileP` already imported line ~20) with an upstream branch ahead/behind → `buildProjects` yields `ahead`/`behind`/`lastCommitMs`; repo with **no** upstream → those `undefined`, no throw; `projectDirtyCheck:false` → sync skipped (undefined). Implement `readSync` + fold into `runDiscovery`.
- [ ] `test/projects-panel.test.ts` (jsdom): `normalizeProject` passes numeric `ahead/behind/lastCommitMs`, drops non-numbers; `makeProjectCard` renders `↑2 ↓1` chip when set, omits when `undefined`/`0`. Implement FE.
**(c) Reconnect digest**
- [ ] `test/http/digest.test.ts` (new, node): `buildDigest([...LiveSessionInfo], since)` — counts finished (`idle` & `lastOutputAt>since`), needsInput (`waiting`), stuck, working; `totalCostUsd` sums `telemetry.costUsd`; empty list → zeroes; `since` in the future → 0 finished.
- [ ] `test/integration/digest-endpoint.test.ts` (new): server up, `GET /digest?since=0` → 200 `DigestResult`; malformed `since` → treated as 0. Implement route.
- [ ] FE `test/digest.test.ts` (jsdom): `mountDigest` fetches with the stored last-seen; renders banner only when `finished+needsInput+stuck>0`; dismiss updates localStorage last-seen and hides; fetch failure → no banner (best-effort). Implement `public/digest.ts` + `main.ts` mount.
**Coverage:** every new pure function (`parseGitLog`, `buildDigest`, `readSync` via `buildProjects`, budget latch, gauge warn) has a direct unit test; routes have integration tests. Keeps the ≥80% gate — the new code is mostly pure/tested; the thin `server.ts` wiring is exercised by the integration tests.
---
## Edge cases & failure modes
- **(a)** No upstream (`@{u}` fatal) → catch → `ahead/behind` undefined; chip hidden. Detached HEAD → `git log -1 --format=%ct` still works (lastCommitMs set), `@{u}` fails (sync hidden). Empty repo (no commits) → both git calls fail → all undefined. Slow git → 2s timeout kill (reuse `GIT_STATUS_TIMEOUT_MS`, `projects.ts:36`). Adds ≤2 spawns/repo bounded by `GIT_CONCURRENCY=8`; gated off entirely when `projectDirtyCheck=false`. `ahead=behind=0` (in sync) → chip omitted (only render when >0).
- **(b)** `costUsd` undefined in a telemetry frame → skip guard (no crossing). Budget `0` → disabled. Latch persists across statusLine frames; a **new** session gets its own latch (per-`Session`). DND/`notifyDone` interplay: `'budget'` is neither `'done'` nor gated, so `shouldSend` sends it unless global DND is on (matches stuck). Late-joining device: gets current telemetry via `manager.ts:139-140`, derives warn from `/config/ui` budget — no missed styling. Push disabled (no VAPID) → latch still flips, broadcast still happens, just no push (graceful, like stuck).
- **(c)** `since` absent/NaN/negative → `0`. No sessions → all-zero `DigestResult` (banner suppressed client-side). Clock skew / `lastOutputAt` in future → still counted as finished if `idle` (acceptable; coarse "what happened" view). First-ever visit (no localStorage) → `since=0`, banner may list everything → set last-seen to `generatedAt` after first render so it doesn't re-nag.
- **(d)** Binary/huge subjects: `maxBuffer` cap + subject truncation. Non-repo/deleted path → 404 (isValidGitDir). Repo with 0 commits → `[]`. Merge commits/unusual chars in subject → US-field + NUL-record delimiters immune to embedded whitespace. `n` non-numeric → clamp to default.
---
## Security
- **Path containment:** `/projects/log` reuses `isValidGitDir` (`server.ts:123`) — absolute + isDirectory + has `.git` (SEC-H7 three-prong), identical to `/projects/diff`. `readSync` runs only against paths already discovered by the bounded BFS scan (`scanRepos`, symlink/dotdir/`node_modules`-skipping, `projects.ts:148`).
- **No shell, ever:** all git calls use `execFile('git', [...])` with `timeout` + `maxBuffer` (mirrors `readDirty` line 100). `repoPath` is passed as `cwd`, never interpolated into argv. `n` is coerced to an int and clamped before reaching argv.
- **Output is untrusted:** commit subjects and digest labels rendered via `textContent` only (SEC-H5, as in `preview-grid.ts` and `projects.ts` worktree/CLAUDE.md rendering) — zero `innerHTML`. FE `normalizeProject`-style narrowing for the new numeric fields (drop non-numbers) and for `/digest`/`/projects/log` responses (never trust the API shape).
- **Origin/CSRF:** all four routes are **read-only GETs** → no Origin guard, consistent with `/projects`, `/live-sessions`, `/projects/diff`. `GET /config/ui` stays read-only. No state-changing surface is added, so no new `requireAllowedOrigin` / rate-limit needed. (The budget **push** goes out the existing `pushService` seam — no new inbound route.)
- **Secrets:** cost budget is a non-secret number; `costBudgetUsd` is safe to expose in `/config/ui`. Push payload for `'budget'` carries only sessionId + cwd-basename label (no cost figure, no terminal bytes) via the existing `buildPayload` (`push-service.ts:88`) — SEC-C5 byte-shuttle boundary preserved.
- **DoS:** sync adds bounded spawns (concurrency 8, 2s timeout, gated by `projectDirtyCheck`); `/projects/log` `n` clamped ≤50; `/digest` is O(sessions) over an already-capped table.
---
## Effort & dependencies
**~34 dev-days total** (four independent slices; can be parceled to parallel builders after the shared `src/types.ts` edit lands):
| Sub | Effort | Notes |
|---|---|---|
| (a) sync chip | ~0.50.75d | Backend `readSync` + fold-in + 2 FE renders. |
| (b) cost guard | ~0.75d | Config float parser, manager latch, gauge warn, `/config/ui` + tabs.ts wiring. Highest test surface. |
| (c) reconnect digest | ~1d | New pure aggregate + route + new FE banner module + main.ts mount + localStorage last-seen. |
| (d) recent commits | ~1d | New backend git-log module + route + new FE render module + detail-section wiring. |
**Dependencies (into these):** all four build only on already-shipped infra — `buildProjects` cache pass (v0.6), `NotifyService` DI + `stuckNotified` latch (A5/A1), `renderTelemetryGauge` (B2), `isValidGitDir` + `getDiff` pattern (B1), `/config/ui` seam (review #4). No dependency on other roadmap items.
**Unlocks / synergy:** (d)'s `getGitLog` + NUL-parsing helper and (a)'s upstream detection are reusable by **#9 "diff against a base branch"** (upstream/`@{u}` resolution) and **#10 "PR + CI status chip"** (a per-repo git/`gh` metadata pass can fold into the same `runDiscovery` concurrency slot as the sync chip). (c)'s `buildDigest` gives **#8 idle-queued follow-up** a ready read-side "which sessions are idle/waiting" aggregate. `NotifyClass 'budget'` establishes the pattern for any future threshold alerts.

View File

@@ -0,0 +1,135 @@
# Stage / commit / push from the diff viewer
`id: w4-commit-push` — the highest-risk git **write** set. Scope is bounded to the MVP the task defines: **per-file stage/unstage toggle + commit + push-current-branch**. Discard / checkout / restore-working-tree are explicitly **deferred** (they are the only destructive-to-working-tree ops; nothing in this feature ever touches file contents on disk).
This mirrors the existing read-only diff channel (`src/http/diff.ts`) and the one existing git-write channel (`src/http/worktrees.ts` + the `POST /projects/worktree` route at `src/server.ts:699725`). The server stays a byte-shuttle; these are out-of-band side-channel routes.
---
## Contract
### New routes (all in `src/server.ts`, all `express.json`, all `requireAllowedOrigin` + `isValidGitDir`)
Every route: `requireAllowedOrigin(req, res)` (CSRF, `src/server.ts:352`) → `gitOpsEnabled` gate (403 if off) → per-IP rate limit → validate `path` with the in-file `isValidGitDir` (three-prong: absolute + dir + `.git`, `src/server.ts:123`) → delegate to `src/http/git-ops.ts`. The delegate re-validates and **realpath-contains** every path (defense in depth — the routes `isValidGitDir` does not follow symlinks).
| Method + path | Body | Success | Notes |
|---|---|---|---|
| `POST /projects/git/stage` | `{ path: string, files: string[], stage?: boolean }` | `200 {ok:true, staged:boolean, count:number}` | `stage` default `true``git add`; `false` → unstage (`git restore --staged`). `stage` is an **addition** to the tasks bare `{path,files[]}` so one endpoint serves the toggle in both directions. |
| `POST /projects/git/commit` | `{ path: string, message: string }` | `200 {ok:true, commit:string}` (short SHA) | Commits **staged** changes only. `message` capped at `commitMsgMaxLen`. |
| `POST /projects/git/push` | `{ path: string }` | `200 {ok:true, branch:string, remote:string}` | Pushes the **current branch** to its existing upstream, else `-u <sole-remote> <branch>`. Remote/branch are **derived server-side**, never taken from the client. |
Error shape (mirrors the worktree route at `src/server.ts:724`): `res.status(result.status).json({ error: result.error })` where `error` is a **safe** message (never raw git stderr, SEC-M10 — see `classifyWorktreeError`, `src/http/worktrees.ts:193`).
Status codes: `400` invalid input / detached HEAD / no remote / identity unset; `403` disabled or bad Origin; `404` not a git dir; `409` nothing staged / non-fast-forward / index.lock held; `401` push auth required; `429` rate-limited; `500` unclassified; `200` success.
### `src/types.ts` (coordination edit — frozen shared-contract source)
Add, next to `CreateWorktreeResult` (`src/types.ts:485`):
```
export interface GitOpResult {
ok: boolean;
status?: number; // HTTP status on failure
error?: string; // SAFE message only (never raw git stderr)
// success payloads (route-specific, all optional):
staged?: boolean; // stage: direction applied
count?: number; // stage: files affected
commit?: string; // commit: short SHA
branch?: string; // push: branch pushed
remote?: string; // push: remote pushed to
}
```
One interface keeps the coordination churn minimal (like `CreateWorktreeResult`). The FE narrows with an `isGitOpResult` guard mirroring `isWorktreeResult` (`public/projects.ts:545`).
### `src/config.ts` env vars (4 new, mirroring the B3 worktree group at `src/config.ts:371378`)
| Env | Field | Default | Purpose |
|---|---|---|---|
| `GIT_OPS_ENABLED` | `gitOpsEnabled: boolean` | `true` | Master kill-switch (mirrors `worktreeEnabled`, `src/config.ts:372`). Off → all three routes 403. |
| `GIT_OPS_TIMEOUT_MS` | `gitOpsTimeoutMs: number` | `10_000` | stage/commit exec timeout (mirrors `DEFAULT_WORKTREE_TIMEOUT_MS`, `src/config.ts:65`). |
| `GIT_PUSH_TIMEOUT_MS` | `gitPushTimeoutMs: number` | `120_000` | push is network-bound → longer bound. |
| `COMMIT_MSG_MAX_LEN` | `commitMsgMaxLen: number` | `5_000` | commit-message length cap. |
Parsed with the existing `parseBool` / `parseNonNegativeInt` helpers and appended to the frozen object (`src/config.ts:413438`). File-count cap for `stage` reuses the existing `diffMaxFiles` (`src/config.ts:358`, default 300) — no new var. `src/types.ts` `Config` interface gains the 4 fields (same coordination note the file already carries at `src/config.ts:15`).
---
## Files to change
| Path | Change |
|---|---|
| **`src/http/git-ops.ts`** (new) | The whole write engine. Mirrors `diff.ts`/`worktrees.ts` execFile pattern: no shell, `timeout`+`maxBuffer`, `--` terminator, `env:{...process.env, GIT_TERMINAL_PROMPT:'0'}`. Exports (all never-throw, return `GitOpResult`): `stageFiles(repoPath, files, stage, opts)`, `commit(repoPath, message, opts)`, `push(repoPath, opts)`, plus pure `classifyGitError(stderr): {status,error}` and `validateRepoFiles(repoRealPath, files): string[] \| null`. |
| **`src/http/git-path.ts`** (new, small) | Extracts the containment helper so it isnt duplicated: `resolveRealPath(target)` (copy of `worktrees.ts:118`) + `isContained(realBase, realCandidate): boolean`. Used by `git-ops.ts`. (Optional follow-up: refactor `worktrees.ts:141` `computeWorktreeDir` onto it — **deferred**, out of this tasks lane.) |
| **`src/types.ts`** | Coordination edit: add `GitOpResult` (above) + 4 `Config` fields. |
| **`src/config.ts`** | Parse the 4 new env vars; append to the frozen config object. |
| **`src/server.ts`** | Register the 3 routes after the worktree route (`:725`). Add two module-level rate constants + two limiters via `createRateLimiter` (`:109`). Reuse in-file `isValidGitDir`, `requireAllowedOrigin`, `sanitizeForLog` (`:162`) for the commit/push audit log. Import from `./http/git-ops.js`. |
| **`public/diff.ts`** | `mountDiffViewer` (`:253`) gains: per-file **Stage/Unstage** toggle button in each file row, and a bottom **commit/push bar** (message `<textarea>` + Commit + Push). Add optional `onToggleStage` to the render path; add POST helpers `postStage/postCommit/postPush`. All text via `textContent`/`el()` (SEC-H4, `:139` note). Re-`loadDiff()` after any successful write. |
| **`public/projects.ts`** | No logic change required — `buildDiffSection` (`:612`) already mounts `mountDiffViewer` and gets the new UI for free. Only add CSS-class hooks if styling inline. |
| **`public/style.css`** (or wherever `df-*` classes live) | Styles for `df-file-stage`, `df-commitbar`, `df-commit-msg`, `df-commit-btn`, `df-push-btn`, `df-op-error`, `df-op-busy`. |
---
## TDD steps (ordered)
Backend uses **node env + real throwaway git repos** in `os.tmpdir()` exactly like `test/http/diff.test.ts:277` and `test/http/worktrees-create.test.ts:42`. FE uses **jsdom + mocked `fetch`** like `test/diff.test.ts`.
**Pure classifier first (fast, deterministic):**
1. `test/http/git-ops.test.ts``describe('classifyGitError')`: feed canned stderr strings, assert `{status,error}` and that `error` never contains `fatal:` (SEC-M10, mirror `worktrees-create.test.ts:193`). Cases: `nothing to commit`→409; `Please tell me who you are`→400; `! [rejected]`/`non-fast-forward`/`fetch first`→409; `could not read from Username`/`Authentication failed`/`terminal prompts disabled`/`Permission denied (publickey)`→401; `index.lock`→409; unknown→500. → then implement `classifyGitError`.
2. `describe('validateRepoFiles')`: rejects `[]`, absolute paths, leading-`-`, `../escape`, > `diffMaxFiles` entries; accepts in-repo relative paths (incl. a **deleted** file whose path no longer exists on disk — `resolveRealPath` resolves the existing prefix). Symlink-escape: pre-plant a symlink inside the repo pointing outside, assert rejection (mirror `worktrees-create.test.ts:137`). → implement `validateRepoFiles` on `git-path.ts`.
**Integration against real repos:**
3. `describe('stageFiles')`: init repo + commit; modify a tracked file + add an untracked file; `stageFiles(repo,[file],true)` → assert `git diff --cached --name-only` lists them; `stage:false` → assert unstaged. Assert non-git path → `{ok:false,status:404}`. → implement `stageFiles`.
4. `describe('commit')`: stage a change → `commit(repo,'msg')``{ok:true, commit}` and `git log -1 --format=%H` starts with it. Empty index → `{ok:false,status:409}`. Repo with `user.name` unset → 400. Message length > cap → 400 (route-level; test the length guard where it lives). → implement `commit`.
5. `describe('push')`: create a **bare** repo as `origin` (`git init --bare`), `git remote add origin`, first push sets upstream via `-u`; assert bare repo received the ref (`git --git-dir=<bare> rev-parse <branch>`). Detached HEAD → 400. Zero remotes → 400. Two remotes + no upstream → 409. Second push (upstream now set) → plain `git push`. → implement `push`.
**Config + route wiring:**
6. `test/config.test.ts`: the 4 new vars default correctly and parse/validate (reuse existing patterns).
7. `test/integration/server.test.ts` (extend, using the `startServer` + `fetch` + `Origin` pattern at `:783`): for each of the 3 routes — foreign Origin → 403, **no** Origin → 403 (default-deny, mirror `:778`), non-git path → 404, missing field → 400, `gitOpsEnabled:false` → 403, and a happy-path 200 against a temp repo (push against a temp bare remote). Confirm `error` bodies carry no `fatal:`.
**Frontend (jsdom):**
8. `test/diff.test.ts` (extend): stub `global.fetch`. Assert each file row renders a Stage button on the Working view and Unstage on the Staged view; clicking POSTs to `/projects/git/stage` with the right `{path,files,stage}` body and re-fetches. Assert the commit bar disables Commit when the message is empty, POSTs `/projects/git/commit`, and shows a failure via `textContent` (SEC-H4 — assert zero `innerHTML`, mirror the files existing security assertions). Assert Push POSTs `/projects/git/push` and reflects busy/disabled state. → implement the `mountDiffViewer` changes to pass.
Coverage: the pure `classifyGitError`/`validateRepoFiles` + real-repo integration cover `git-ops.ts` branches; steps 78 cover the new server + `diff.ts` lines. Keeps the 80% gate.
---
## Edge cases & failure modes
- **Nothing staged → commit**: git exits non-zero (`nothing to commit`) → 409 "Nothing staged to commit." (not a 500).
- **Author identity unset**: `commit` fails (`Please tell me who you are`) → 400 with a safe hint. Do **not** auto-inject a fake identity.
- **Unborn HEAD (no commits yet)**: unstage via `git restore --staged` errors → classify to a safe 409/400; commit still works (creates the first commit). Documented limitation.
- **Detached HEAD on push**: `git rev-parse --abbrev-ref HEAD``HEAD` → refuse 400 "Cannot push a detached HEAD."
- **No upstream, one remote**: `git push -u <remote> <branch>`. **No upstream, ≥2 remotes**: 409 "Set an upstream first." **Zero remotes**: 400.
- **Non-fast-forward / remote ahead**: `! [rejected]` → 409 "Push rejected — pull/rebase first." (never force-push).
- **Credential prompt hang**: `GIT_TERMINAL_PROMPT=0` (+ optional `GIT_SSH_COMMAND='ssh -o BatchMode=yes'`) makes auth fail fast → 401 instead of hanging; the exec `timeout` is the backstop.
- **`index.lock` held (concurrent op)**: → 409 "Another git operation is in progress." (Optional hardening: a per-realpath in-memory promise-chain mutex in `server.ts` to serialize writes; gits own lock is the correctness backstop, so this is MEDIUM, not required for MVP.)
- **Deleted file staged**: path absent on disk → `resolveRealPath` resolves the existing prefix and re-appends the basename; `git add -- <path>` records the deletion. **Renamed file**: FE sends both `oldPath` and `newPath` so the deletion+addition both stage.
- **Huge `files[]`**: capped at `diffMaxFiles` → 400.
- **maxBuffer overflow / timeout**: caught, returned as a safe 500 (never a thrown rejection — house style, `diff.ts:302`).
- **Commit message with newlines / leading `-` / emoji**: safe — passed as the single value of `-m` via argv (no shell), length-capped, never a pathspec.
- **Write succeeds but re-fetch fails**: FE shows the diff error state but the write already happened; surface a non-blocking notice.
---
## Security
- **Origin/CSRF**: all 3 routes call `requireAllowedOrigin` (`src/server.ts:352`) — this is a **write** channel, unlike read-only `/projects/diff` which has no guard. Tested for foreign-Origin **and** missing-Origin (default-deny).
- **No shell, ever**: `execFile('git', argv, …)` only — mirrors `diff.ts:296` / `worktrees.ts:242`. Untrusted strings (message, file paths) are argv elements, never interpolated. `--` terminates options before any pathspec so a path cant become a flag; file paths additionally rejected if they start with `-`.
- **Path containment (highest-risk)**: `isValidGitDir` at the route (absolute+dir+`.git`) **plus** `realpath`-based containment of the repo and of **every** `files[]` entry inside `git-ops.ts` (`resolveRealPath` + `startsWith(realBase+sep)`, the M2 pattern from `worktrees.ts:141`). Defeats `../` traversal and pre-planted-symlink escapes even though the diff route only does the lighter three-prong check.
- **Server-derived remote/branch**: push never accepts a remote or refspec from the client — both are read back from the repo — eliminating arg-injection and push-to-arbitrary-URL risk.
- **Safe error classification**: `classifyGitError` maps stderr substrings to safe messages; raw git stderr is never returned (SEC-M10). Tested that responses contain no `fatal:`.
- **Rate limiting**: reuse `createRateLimiter` (`src/server.ts:109`, 60 s window) — `gitWriteLimiter` (stage+commit) ≈ 30/min/IP, `gitPushLimiter` ≈ 6/min/IP; over-limit → 429. Fixed policy constants beside the existing ones (`src/server.ts:7476`).
- **Audit log**: commit/push log actor+path via `sanitizeForLog` (`src/server.ts:162`, control-char strip + truncate), like the worktree audit at `:714`.
- **Kill-switch**: `gitOpsEnabled=false` disables all three (mirrors `worktreeEnabled`, `src/server.ts:701`) for locked-down deployments.
- **Body size**: `express.json({ limit: '4kb' })` for commit/push, `'64kb'` for stage (large `files[]`) — matching the existing routes caps.
- **FE injection**: all rendered git output/errors via `textContent`/`el()` — zero `innerHTML` (SEC-H4, asserted in `test/diff.test.ts`).
---
## Effort & dependencies
- **Estimate**: ~34 days. Backend `git-ops.ts` + classifier + containment ≈ 1.5 d; route wiring + config + integration tests ≈ 0.75 d; FE toggles + commit/push bar + jsdom tests ≈ 1 d; polish/edge-cases ≈ 0.5 d.
- **Depends on**: the existing B1 diff channel (`src/http/diff.ts`, `public/diff.ts:253` `mountDiffViewer`) and B3 worktree channel (`src/http/worktrees.ts`) — both shipped; this reuses their execFile + realpath-containment + error-classification patterns directly. No new dependency on other roadmap items.
- **Shares infra with** `w4-worktree-remove` (task #12): both are git-write routes under `requireAllowedOrigin` + `classifyGitError`; landing the shared `git-path.ts` + `classifyGitError` here de-risks that one. Extractable-later: a generic `runGitWrite()` wrapper could later back `worktrees.ts` too (deferred — out of lane).
- **Unlocks**: an in-browser commit loop for the walk-away workflow (stage → commit → push from a phone), and a natural home for a future "recent commits" chip (task #11).

View File

@@ -0,0 +1,147 @@
# Worktree remove / prune
Delete losing worktrees and prune stale ones from any device. Backend adds `removeWorktree`/`pruneWorktrees` to `src/http/worktrees.ts` (same execFile-no-shell + timeout + containment machinery as the shipped `createWorktree`), two Origin-guarded routes in `src/server.ts`, and the FE makes the already-rendered `locked`/`prunable`/main tags in `public/projects.ts` `makeWorktreeRow` (lines 492502) actionable.
## Contract
### New backend functions — `src/http/worktrees.ts`
Reuse the private helpers already in the file: `isGitRepo` (line 168), `listWorktrees`/`parseWorktrees` (lines 29, 69), `extractStderr` (line 180), `resolveRealPath` (line 118). Match `createWorktree`'s structured-result-never-throws contract (line 219).
```ts
export interface RemoveWorktreeOptions { readonly force?: boolean; readonly timeoutMs: number }
export async function removeWorktree(
repoPath: string, targetPath: string, opts: RemoveWorktreeOptions,
): Promise<RemoveWorktreeResult>
export interface PruneWorktreesOptions { readonly timeoutMs: number }
export async function pruneWorktrees(
repoPath: string, opts: PruneWorktreesOptions,
): Promise<PruneWorktreesResult>
```
`removeWorktree` algorithm (the security spine):
1. `isGitRepo(repoPath)` false → `{ ok:false, status:404, error:'Not a git repository.' }`.
2. `typeof targetPath !== 'string'` or empty → `{ ok:false, status:400, error:'Worktree path is required.' }`.
3. `listWorktrees(repoPath)` → find the entry whose **realpath equals** `resolveRealPath(targetPath)` (canonical compare, not raw-string compare, to defeat symlink tricks). No match → `{ ok:false, status:404, error:'That path is not a worktree of this repository.' }`.
4. Matched entry `.isMain === true``{ ok:false, status:400, error:'Cannot remove the main worktree.' }`.
5. Matched entry `.locked``{ ok:false, status:409, error:'This worktree is locked; unlock it in a terminal first.' }` (never double-force `-f -f`).
6. Run `git worktree remove` **with the canonical path from git's own list entry** (`match.path`), never the raw user string: `['worktree','remove', ...(force?['--force']:[]), '--', match.path]` via `execFileAsync` (cwd `repoPath`, `timeout: opts.timeoutMs`, `maxBuffer: WORKTREE_MAX_BUFFER`). On success `{ ok:true, path: match.path }`.
7. On error → `classifyRemoveError(err)` (new; sibling of `classifyWorktreeError` line 193): stderr containing `contains modified or untracked files` / `use --force` / `is dirty``{ ok:false, status:409, error:'Worktree has uncommitted changes — force required.' }`; `not a working tree`/`is not a working tree``{ ok:false, status:404, ... }`; else `{ ok:false, status:500, error:'Failed to remove the worktree.' }`. Never leak raw stderr (SEC-M10).
`pruneWorktrees` algorithm: `isGitRepo` gate (404), then `git worktree prune -v` via execFile (timeout/maxBuffer). Parse verbose lines (`Removing worktrees/<name>: <reason>`, emitted on stdout/stderr — capture both) into `pruned: string[]` (best-effort; empty when nothing prunable — idempotent). Error → `{ ok:false, status:500, error:'Failed to prune worktrees.' }`.
### Changed message types — `src/types.ts` (coordination edit)
Add next to `CreateWorktreeResult` (line 485):
```ts
export interface RemoveWorktreeResult { ok: boolean; path?: string; status?: number; error?: string }
export interface PruneWorktreesResult { ok: boolean; pruned?: string[]; status?: number; error?: string }
```
`WorktreeInfo` (line 290), `Config` worktree fields (lines 6769) unchanged. Option interfaces stay local to `worktrees.ts` (mirrors `CreateWorktreeOptions`, line 207).
### New routes — `src/server.ts`
Insert both immediately after the create route (ends line 725), reusing `requireAllowedOrigin` (line 352), `sanitizeForLog` (line 162), `cfg.worktreeEnabled`, `cfg.worktreeTimeoutMs`. Add `removeWorktree, pruneWorktrees` to the import at line 43.
| Route | Body / gate | Response |
|---|---|---|
| `DELETE /projects/worktree` | `express.json({limit:'4kb'})`; `{ path, worktreePath, force? }`. Guard order: `requireAllowedOrigin``worktreeEnabled` (403) → `path`+`worktreePath` present (400). Audit-log via `sanitizeForLog`. | `result.ok``200 {ok:true,path}`; else `result.status ?? 500` + `{error}` |
| `POST /projects/worktree/prune` | `express.json({limit:'4kb'})`; `{ path }`. Same guard order (path required → 400). | `200 {ok:true,pruned}` or `result.status` + `{error}` |
`force` coerced strictly: `const force = body['force'] === true`.
### Env vars — `src/config.ts`
**None new.** Reuse `worktreeEnabled` (line 372, gates all worktree writes), `worktreeTimeoutMs` (line 374) for remove/prune timeouts.
### FE — `public/projects.ts`
- New module-level fetch helpers (siblings of `killSession`, line 275): `removeWorktreeReq(repoPath, worktreePath, force)``DELETE /projects/worktree` with JSON body, returns `{ok, status, error}`; `pruneWorktreesReq(repoPath)``POST /projects/worktree/prune`, returns `{ok, pruned?, error?}`. Both same-origin (Origin guard passes), best-effort catch.
- `makeWorktreeRow` (line 492) gains an optional `actions?: { onRemove:(wt)=>void }` param. When `actions` present and **not** `wt.isMain` and **not** `wt.locked`: append a `proj-wt-remove` `✕` button (`aria-label` "Remove worktree", `title` "Remove this worktree"). Locked rows keep the `locked` tag but no remove button (tooltip explains). Prunable rows still get remove.
- `DetailCallbacks` (line 666) gains `onRemoveWorktree:(worktreePath:string)=>void` and `onPruneWorktrees:()=>void`. `renderProjectDetail` (line 672) threads `actions` into the worktree-list loop (line 721) and, when `detail.worktrees.some(w=>w.prunable)`, renders a section-level `Prune stale worktrees` button beside the "Worktrees" title (line 712) wired to `cb.onPruneWorktrees`.
- `mountProjects` (line 820) implements the confirm→force→refresh flow (encapsulated here, like `killAndRefresh` line 897), passed into `renderDetail` (line 959):
- `onRemoveWorktree`: `confirm("Remove worktree at <path>? This deletes the working tree.")``removeWorktreeReq(detailPath, wtPath, false)`; on `409` → second `confirm("Uncommitted changes will be lost. Force-remove?")` → retry with `force:true`; on other failure show error via `textContent` (never innerHTML, SEC-L3/H6); then `void refresh()`.
- `onPruneWorktrees`: `confirm("Prune worktrees whose folders are gone?")``pruneWorktreesReq(detailPath)``refresh()`.
- `public/diff.ts`: **no change**`grep` confirms it renders no worktree rows/tags; the task's mention is covered entirely by `projects.ts`. (Note this deviation from the task wording in the log.)
## Files to change
| Path | Change |
|---|---|
| `src/types.ts` | **Coordination edit:** add `RemoveWorktreeResult`, `PruneWorktreesResult` after line 491. |
| `src/http/worktrees.ts` | Add `removeWorktree`, `pruneWorktrees`, `RemoveWorktreeOptions`, `PruneWorktreesOptions`, `classifyRemoveError`; reuse existing `isGitRepo`/`listWorktrees`/`resolveRealPath`/`extractStderr`. |
| `src/server.ts` | Import (line 43) + two routes after line 725 (`DELETE /projects/worktree`, `POST /projects/worktree/prune`). |
| `public/projects.ts` | `removeWorktreeReq`/`pruneWorktreesReq` helpers; extend `makeWorktreeRow` (492), `DetailCallbacks` (666), `renderProjectDetail` (672), `mountProjects` (820). |
| `test/http/worktrees-remove.test.ts` | **New** — node, real temp repos; unit + integration of the two functions. |
| `test/integration/worktree.test.ts` | Extend with `DELETE`/prune route cases. |
| `test/worktree-form.test.ts` | **New describe blocks** — jsdom FE row/confirm/prune tests. |
## TDD steps (ordered)
Backend pure/logic — `test/http/worktrees-remove.test.ts` (node env, mirror `worktrees-create.test.ts`: `makeRepo()` helper, `gitAvailable` guard, real temp repos, no network):
1. Test `removeWorktree` returns `{ok:false,status:404}` for a non-git dir → implement `isGitRepo` gate.
2. Test empty/missing `targetPath``{ok:false,status:400}` → implement guard.
3. Test a path not in `git worktree list``{ok:false,status:404}` → implement realpath-match against `listWorktrees`.
4. Test main worktree (the repo root) → `{ok:false,status:400,error:/main/}` → implement `isMain` reject.
5. Test happy path: create a worktree via `createWorktree`, then `removeWorktree` (clean tree) → `{ok:true}`; assert `git worktree list` no longer contains it.
6. Test dirty worktree (write an untracked file into it) → `removeWorktree(force:false)``{ok:false,status:409}`; then `force:true``{ok:true}` → implement `classifyRemoveError` + force arg.
7. Test error message never contains `fatal:`/`error:` (SEC-M10) → assert on the 409 case.
8. Test locked worktree (`git worktree lock`) → `{ok:false,status:409,error:/locked/}` → implement locked reject.
9. Test symlink alias: pass a symlink pointing at a real worktree as `targetPath` → still matches via realpath and removes git's canonical path → verify (M2-style containment).
10. Test `pruneWorktrees` on a repo with a manually-`rm -rf`'d worktree dir → `{ok:true, pruned:[...]}` length ≥1; on a clean repo → `{ok:true, pruned:[]}` (idempotent). Non-git → `{ok:false,status:404}`.
Backend route — extend `test/integration/worktree.test.ts` (reuse `spawnServer`, `makeRealRepo`, `itGit`):
11. `DELETE /projects/worktree` foreign Origin → 403; missing Origin → 403.
12. `WORKTREE_ENABLED=0` → 403.
13. Missing `worktreePath` → 400.
14. Attempt to remove main worktree → 400.
15. `itGit`: create worktree via `POST /projects/worktree`, then `DELETE` it (clean) → 200 `{ok:true}`; `git worktree list` no longer lists it.
16. `itGit`: dirty worktree → DELETE without force → 409; with `force:true` → 200.
17. `POST /projects/worktree/prune`: Origin 403, disabled 403, and `itGit` prune-after-manual-delete → 200 with `pruned`.
FE — `test/worktree-form.test.ts` (jsdom; extend, reuse `makeDetail`, `makeHooks`, `makeCbs`, stubbed `fetch`):
18. `makeWorktreeRow` for a non-main non-locked wt with `actions` → contains a `.proj-wt-remove` button; main and locked rows → no button.
19. `renderProjectDetail` with a prunable worktree → a `Prune stale worktrees` button exists; without → absent.
20. Clicking Remove: stub `window.confirm=()=>true`, stub `fetch``{ok:true}`; assert `fetch` called `DELETE /projects/worktree` with `force:false` in body.
21. Dirty retry: `fetch` first resolves `{ok:false,status:409}` then `{ok:true}`; `confirm` returns true twice → assert second `fetch` body has `force:true`.
22. `confirm` returns false → assert `fetch` **not** called (no accidental deletion).
23. Error render: `fetch``{ok:false,status:500,error:'boom'}` → error shown via `textContent` (assert `.textContent`, no HTML injection).
Run `npm test`; keep the 80% gate — the new functions and both routes carry direct + error-path coverage; FE branches (button presence, confirm true/false, force retry, error) are all exercised.
## Edge cases & failure modes
- **Main worktree** — always rejected (400); the repo root can never be deleted.
- **Not-a-worktree path** — arbitrary FS path (e.g. `/etc`) never matches the list → 404, git never invoked against it.
- **Dirty tree** (modified tracked or untracked files) — git refuses without `--force`; surfaced as 409 → explicit second confirm before force.
- **Locked worktree** — 409 with a "unlock first" message; UI hides the remove button; never auto-escalate to `-f -f`.
- **Removing the worktree you're viewing** (`isCurrent` but not `isMain`) — allowed; after refresh the detail path may 404 → detail shows "Project not found" (existing null branch, line 691). Acceptable.
- **Already-removed / concurrent delete** — git errors "not a working tree" → 404 safe message; the follow-up `refresh()` reconciles the UI.
- **Prune with nothing prunable** — `{ok:true, pruned:[]}`, no error (idempotent).
- **git binary missing / timeout** — execFile rejects → 500 safe message; timeout bounded by `worktreeTimeoutMs`.
- **Path with control chars / flag-like leading `-`** — never reaches argv as-is: we pass git's own canonical list path, and `--` terminates options; audit log runs through `sanitizeForLog`.
- **DELETE-with-body stripped by an intermediary** — same-origin fetch, no proxy in the LAN threat model; body reliably delivered. (If ever a concern, mirror as query params — noted, not implemented.)
## Security
- **Origin/CSRF:** both routes call `requireAllowedOrigin` first (line 352) — destructive state change, mandatory (SEC-C3). Integration tests 1112, 17 assert 403 for foreign/missing Origin.
- **Feature gate:** `cfg.worktreeEnabled` (403 when off) governs remove/prune exactly as it governs create.
- **No-shell exec:** `execFile('git', [...])` only; never a shell string. `timeout` + `maxBuffer` bound resource use.
- **Path containment (the core defense):** the target is accepted **only** if its realpath matches an entry git itself reports in `worktree list`, and the command runs against **git's canonical path**, not the user string — so no traversal/symlink/arbitrary-path deletion is reachable (M2-consistent). `--` belt-and-suspenders before the path arg.
- **Main-worktree protection:** `isMain` reject prevents deleting the repository itself.
- **Safe error messages:** `classifyRemoveError`/prune mapping return fixed strings; raw git stderr never returned (SEC-M10) — asserted in test 7.
- **FE injection:** error/label rendering uses `textContent` only (SEC-L3/H6), asserted in test 23.
- **Destructive-intent confirmation:** browser `confirm()` before any delete, plus a **second** confirm before `force` on a dirty tree — no single-click data loss.
- **Rate-limit:** inherits the app's per-connection posture; these are Origin-gated same-origin calls. (No new limiter added — consistent with the create route.)
## Effort & dependencies
- **Effort:** ~1.52 days (backend + routes ~0.75d, FE wiring + confirm flow ~0.5d, tests ~0.5d).
- **Depends on:** W4 *create worktree* (shipped `createWorktree`, whose `isGitRepo`/`listWorktrees`/`resolveRealPath`/`classify*` are reused) and the v0.6 project-detail worktree list (`makeWorktreeRow`).
- **Unlocks:** full worktree lifecycle from any device (create → work → **remove/prune losers**), and pairs naturally with W4 *stage/commit/push from the diff viewer* (issue #13) to close the "spin up a worktree, land the winner, delete the rest" loop.

View File

@@ -0,0 +1,155 @@
# App-level access token (leave-the-LAN bar-raiser)
> **Feature id:** `w5-access-token` · **Effort: M** · Branch: `develop`
> Adds an **optional** shared secret (`WEBTERM_TOKEN`) that gates remote HTTP + the WS handshake with a constant-time-compared, `HttpOnly`/`SameSite=Strict` cookie. **Unset ⇒ auth disabled**, preserving today's LAN zero-config. The token is **additive** — it sits *in front of* the existing Origin/CSRF model (`isOriginAllowed` at `src/http/origin.ts:22`, `requireAllowedOrigin` at `src/server.ts:480`), never replaces it.
---
## Honest tradeoff (read first — belongs in the PR description too)
This is a **bar-raiser, not a TLS/Tailscale substitute.** On bare LAN the terminal stream is still `ws://` (plaintext) — see `buildWsUrl` at `public/terminal-session.ts:47`, which only upgrades to `wss` when the *page* is HTTPS. When the token travels over `ws://`/`http://`, **anyone sniffing the LAN sees the cookie/token in cleartext.** The token only meaningfully hardens the **relay/tunnel path**, where the edge terminates TLS and the browser speaks `wss://`/`https://`. It is a *single shared secret* (no per-user identity, no revocation except changing the env var + restart, no lockout beyond rate-limiting). Ship it with that framing; do not let it read as "now it's safe on the public internet."
---
## Contract (routes / messages / env / types)
### Env (new — `src/config.ts`)
| Env var | Type | Default | Meaning |
|---|---|---|---|
| `WEBTERM_TOKEN` | string \| undefined | **unset** | Shared access token. **Unset/empty ⇒ auth DISABLED** (everything open, exactly as today). When set: **validated at load** — must match `^[A-Za-z0-9._~+/=-]{16,512}$` (cookie/URL-safe charset, min 16 chars). Invalid ⇒ **throw** (fail-fast, like `parsePort`). |
| `WEBTERM_TOKEN_TTL` | number (sec) | `2592000` (30d) | *(optional, YAGNI-dial)* Cookie `Max-Age`. Parsed via the existing `parseNonNegativeInt` helper (`src/config.ts:83`). Ship the constant first; only wire the env if trivial. |
Charset validation is **not cosmetic**: it blocks `Set-Cookie` header/response-splitting injection and query-string ambiguity, and the ≥16 floor multiplies brute-force cost against the rate limiter.
### Config type (`src/types.ts`)
Add one field to `Config` (interface at `src/types.ts:21`, alongside the other secret-ish fields near lines 4447 / 82):
```
readonly webtermToken: string | undefined; // WEBTERM_TOKEN; undefined ⇒ auth disabled (SECRET — never log/expose)
```
Follow the `vapidPrivateKey` precedent (`src/config.ts:349`): read as `env['WEBTERM_TOKEN'] || undefined`, **never** log it, **never** return it over `/config/ui`.
### New pure module `src/http/auth.ts` (mirrors the shape/discipline of `src/http/origin.ts`)
```
export const AUTH_COOKIE_NAME = 'webterm_auth'
export function isAuthEnabled(cfg: Config): boolean // webtermToken != null && != ''
export function parseCookieHeader(header: string | undefined): Record<string, string>
export function constantTimeEqual(a: string, b: string): boolean // SHA-256 both → timingSafeEqual (fixed-length guard)
export function cookieIsAuthed(cfg: Config, cookieHeader: string | undefined): boolean
export function buildSetCookie(cfg: Config, opts: { secure: boolean }): string // the Set-Cookie value
export function isHttpsRequest(req: IncomingMessage): boolean // x-forwarded-proto==='https' || socket.encrypted
```
- **`constantTimeEqual`** hashes *both* inputs with `crypto.createHash('sha256')` to a fixed 32 bytes, then `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard": it removes the length side-channel **and** avoids `timingSafeEqual`'s throw-on-length-mismatch. (Present-vs-absent, i.e. undefined/empty cookie, short-circuits to `false` — a missing cookie is not a secret-comparison oracle.)
- **`buildSetCookie`** returns:
`webterm_auth=<token>; Path=/; Max-Age=<ttl>; HttpOnly; SameSite=Strict` **+ `; Secure`** only when `opts.secure` is true. Dynamic `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`, so forcing it would silently break LAN-over-HTTP auth; over the relay (`x-forwarded-proto: https`) it must be present.
### New/changed HTTP routes (`src/server.ts`)
| Route | Guard | Behavior |
|---|---|---|
| `GET /login` | **always reachable** (registered *before* the gate) | Serves the self-contained `public/login.html`. |
| `POST /auth` | **always reachable**, rate-limited (`authLimiter`, 10/min/IP) | Body `token` (accepts `urlencoded` *and* `json`, `limit:'1kb'`). Valid ⇒ `Set-Cookie` (`buildSetCookie`) + **302 → `/`** (native-form path) or **204** for XHR. Invalid ⇒ **401** (+ `302 → /login?e=1` for form navigations). Over rate limit ⇒ **429**. |
| `GET /?token=<t>` | handled **inside the gate**, rate-limited | Bootstrap link. Valid ⇒ `Set-Cookie` + **302 → same path with `token` stripped** (no token left in history; `Referrer-Policy: no-referrer` already set at `src/server.ts:307`). Invalid ⇒ **302 → /login**. |
| **The global auth gate** (`app.use`, new) | — | Runs after the security-headers middleware (`src/server.ts:304`) and **before** `express.static` (`src/server.ts:317`). See allow-list below. |
### WS handshake (`src/server.ts` upgrade handler, `:1130`)
After the existing Origin check passes (`:11411146`) and **before** `wss.handleUpgrade` (`:1149`), insert:
> if `isAuthEnabled(cfg)` and **not** `cookieIsAuthed(cfg, req.headers['cookie'])` → `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return`.
The browser auto-sends the `HttpOnly` cookie on the same-origin WS handshake — **no frontend change to `buildWsUrl`/`connect()` is required** for the authed path.
### The gate's allow-list (single central policy point — the `origin.ts:13` idiom)
In order, the gate:
1. `!isAuthEnabled(cfg)``next()` *(zero-config LAN unchanged)*.
2. `isLoopback(req.socket.remoteAddress)` (`src/server.ts:160`) → `next()` *(loopback hook ingest — `POST /hook` `:533`, `/hook/permission` `:561`, `/hook/status` `:890` — has no cookie and must keep working; the token is about **remote** access).*
3. `GET` with `?token=` → validate (rate-limited) → set-cookie+redirect, or → `/login`.
4. `cookieIsAuthed(...)``next()`.
5. else unauthed: `Accept: text/html` navigation → **302 → /login**; otherwise → **401** JSON `{ error: 'authentication required' }`.
**Scope note (decision to surface at review):** this gate is a **superset** of the ROADMAP's stated "WS + `requireAllowedOrigin` routes." It *also* gates the read-only GET side-channels (`/live-sessions`, `/projects`, **`/projects/diff` — which leaks source**, `/sessions` — which leaks prompts, `/config/ui`, etc.). That is deliberate: for a "don't-expose-a-shell-off-LAN" bar-raiser, leaving source/prompt reads open off-LAN is the bigger hole, and one central gate is simpler + DRYer than threading a token check through ~19 route handlers. `requireAllowedOrigin` is left **untouched** so the CSRF layer stays independent (defense in depth: gate = "are you authorized to be here", Origin = "is this request forged cross-site"). If a reviewer wants the strictly-minimal scope instead, the fallback is a `requireToken(req,res)` helper called only inside `requireAllowedOrigin` + the WS check — but the global gate is recommended.
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | Add `readonly webtermToken: string \| undefined` to `Config` (interface `:21`, near the secret fields `:4447`). **Coordination point** — this is the frozen shared contract; do it here, not locally. Optionally add `authRequired?: boolean` to `UiConfig` (`:656`) for the FE expiry hint. |
| `src/config.ts` | In `loadConfig` (`:271`): read `WEBTERM_TOKEN` (`env['WEBTERM_TOKEN'] \|\| undefined`), validate charset+min-length when present (throw on bad, like `parsePort` `:170`), add `webtermToken` to the frozen return (`:474`). Never log it. *(Optional: parse `WEBTERM_TOKEN_TTL`.)* |
| `src/http/auth.ts` | **NEW.** Pure, dependency-light helpers listed in Contract (`isAuthEnabled`, `parseCookieHeader`, `constantTimeEqual`, `cookieIsAuthed`, `buildSetCookie`, `isHttpsRequest`, `AUTH_COOKIE_NAME`). Imports `createHash`, `timingSafeEqual` from `node:crypto`. No Express/DOM types — keep it unit-testable like `origin.ts`. |
| `src/server.ts` | (1) import the auth helpers (near `:36`). (2) Add `AUTH_RATE_MAX = 10` to the rate-limit constants (`:8086`). (3) Instantiate `const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (near `:227`). (4) Register `GET /login` + `POST /auth` **then** `app.use(authGate)` between `:313` and `:317`. (5) `authGate` closure implements the allow-list (reuses `isLoopback` `:160`, `requireAllowedOrigin` untouched). (6) WS upgrade: insert the cookie check after Origin (`:1146`, before `:1149`). Serve `login.html` from a startup-cached read of `path.join(publicDir,'login.html')`. |
| `public/login.html` | **NEW, fully self-contained.** A `<form method="POST" action="/auth">` with a `type="password"` token field + submit. **Inline `<style>` only, NO inline `<script>`** — the CSP at `src/server.ts:310` is `script-src 'self'` (blocks inline JS) but `style-src 'self' 'unsafe-inline'` (allows inline CSS). Native form POST → server 302 → cookie set → app loads; **needs zero JS**. Show an error banner when `?e=1`. |
| `public/terminal-session.ts` | *(OPTIONAL polish, low priority)* On WS close-before-`attached` while auth is enabled, print a `statusLine` (`:38`) hint like "Locked — reload to sign in" instead of silent reconnect loops. Covers the cookie-expired-mid-session case. |
| `docs/ROADMAP.md`, `CLAUDE.md`, `.env`/README env list | Tick the ROADMAP item (`:124`); document `WEBTERM_TOKEN` in the env-var list (CLAUDE.md "Planned Commands" block) with the honest-tradeoff sentence. |
| `docs/PROGRESS_LOG.md` | Orchestrator appends the completion entry (not the builder). |
---
## TDD steps (ordered; matches repo vitest style — pure-unit + `test/integration/*` real-server)
**Write each test RED first, then implement to GREEN.** Follow AAA and descriptive names, per the repo's `test/origin.test.ts` / `test/integration/server.test.ts` conventions.
1. **`test/auth.test.ts` (unit, like `origin.test.ts`)**
- `constantTimeEqual`: equal strings → `true`; unequal same-length → `false`; **different length → `false`** (no throw); empty/`undefined``false`.
- `parseCookieHeader`: `'a=1; webterm_auth=xyz; b=2'` → map; missing/empty header → `{}`; malformed pairs ignored.
- `isAuthEnabled`: undefined/empty → `false`; set → `true`.
- `cookieIsAuthed`: correct cookie → `true`; wrong value → `false`; absent cookie → `false`; disabled cfg → design choice (assert `false` and gate short-circuits before calling it).
- `buildSetCookie`: asserts substrings `HttpOnly`, `SameSite=Strict`, `Path=/`, `Max-Age=`; **`Secure` present iff `opts.secure`**; token value present.
2. **`test/config.test.ts` (extend existing)**
- `WEBTERM_TOKEN` unset → `cfg.webtermToken === undefined`.
- Valid token (≥16, safe charset) → stored verbatim.
- Too short (`'abc'`) → `loadConfig` **throws**.
- Bad charset (contains `;`, space, control char) → **throws**.
3. **`test/integration/auth.test.ts` (NEW real-server, pattern from `server.test.ts`)** — extend `makeTestConfig` to accept `WEBTERM_TOKEN`.
- **Regression / disabled:** no token set → WS connects with Origin only; `DELETE /live-sessions` with valid Origin works; `GET /live-sessions` open. *(Proves zero-config LAN is untouched.)*
- **WS enabled:** valid Origin + **no cookie → 401** (`waitForOpen` rejects, mirroring the `:262` bad-Origin test); valid Origin + **valid `Cookie: webterm_auth=<t>` → connects → `attached`**; valid Origin + **wrong cookie → 401**.
- **`POST /auth`:** wrong token → 401; correct token → 302 + `Set-Cookie` (assert flags; **no `Secure` over http**); `>10` bad attempts/min → **429**.
- **`x-forwarded-proto: https`** on `POST /auth``Set-Cookie` **includes `Secure`**.
- **`GET /?token=<valid>`** → 302 to `/` (Location has no `token`) + `Set-Cookie`; **`?token=<invalid>`** → 302 `/login`, no cookie.
- **`GET /login`** → 200 HTML, reachable while unauthed.
- **Unauthed HTML nav** (`GET /`, `Accept: text/html`, no cookie) → 302 `/login`; **unauthed XHR** (`GET /live-sessions`, no cookie) → 401 JSON.
- **Gate + CSRF stacking:** `DELETE /live-sessions` valid Origin, **no cookie → 401** (gate); with cookie → passes gate, then Origin check as before.
- **Loopback bypass:** `POST /hook` from 127.0.0.1 with **no cookie** still returns 204 (hooks unaffected). *(Guard against regressing the side-channel.)*
4. Run `npm test` (vitest) + `npm run typecheck`; confirm the 80% coverage gate holds for `src/http/auth.ts` and the new server branches.
---
## Edge cases
- **Cookie-less non-browser clients (curl/scripts):** already rejected by the WS Origin check (undefined Origin → `false`, `origin.ts:26`); the token gate adds a second wall for HTTP. Intended.
- **Cookie expiry mid-session:** loaded FE keeps a live WS but new WS reconnects/`/config/ui` fetches start 401ing → the OPTIONAL `terminal-session.ts` hint tells the user to reload. No crash.
- **`SameSite=Strict` + bootstrap link:** the `?token=` response *sets* the cookie and 302-redirects; the follow-up top-level navigation to `/` carries it (Strict permits top-level same-site sends). Works.
- **Token with URL-reserved chars:** prevented by the config-load charset validation (no `%`-encoding ambiguity, no cookie/header injection).
- **`?token=` on a non-GET or with a body:** gate only intercepts `?token=` for GET; other methods fall through to the cookie check.
- **PWA offline shell after expiry:** `sw.js` may serve the cached shell offline (same-origin, no data) but the WS still 401s — no data leak.
- **Rate-limiter memory:** reuses the existing in-memory sliding-window `createRateLimiter` (`:118`); per-IP arrays are pruned per call — no unbounded growth for the auth endpoint beyond active IPs.
- **Login page assets vs. gate:** `login.html` must reference **no** external CSS/JS (inline-styles-only) so the gate can 401 everything else including `/build/main.js` while unauthed.
- **Trailing-slash / `/index.html`:** treat both `/` and `/index.html` navigations the same in the "HTML nav → /login" branch.
## Security
- **Constant-time compare** via SHA-256→`timingSafeEqual` (no length or content timing oracle; no throw). ✔ security.md secret-handling.
- **Cookie flags:** `HttpOnly` (JS can't read the token → XSS can't exfiltrate it), `SameSite=Strict` (cross-site pages can't ride the cookie — complements the Origin/CSWSH defense and `requireAllowedOrigin`), `Secure`-when-https, `Path=/`.
- **Rate limiting** on `/auth` and `?token=` (10/min/IP) raises brute-force cost; combined with the ≥16-char token floor this is not trivially guessable. No account lockout (single shared secret) — documented.
- **Secret hygiene:** `webtermToken` never logged (follows `vapidPrivateKey` at `:349`), never returned by `/config/ui` (`:1099`). Charset validation blocks `Set-Cookie`/response-splitting injection.
- **Additive, non-breaking:** Origin check (`origin.ts:22`) and `requireAllowedOrigin` (`:480`) are unchanged; the gate is a new earlier layer. Loopback hook ingest is explicitly bypassed so the smart-features side-channel keeps working.
- **Honest boundary (repeat in code comments + PR):** plaintext on bare `ws://` — token is a relay/tunnel hardener, **not** a TLS/Tailscale replacement. Keep the "never port-forward this raw" guidance from TECH_DOC §7 intact.
- **Security-review trigger:** this is auth + cookie + crypto code → run the `security-reviewer` before merge per code-review.md.
## Effort & dependencies
- **Effort: M** (matches ROADMAP `:129`). No new npm deps — `node:crypto` (`timingSafeEqual`/`createHash`) and Express's built-in `urlencoded`/`json` parsers cover it. `~1` new pure module + `~1` new HTML file + surgical server wiring.
- **Hard dependencies:** none — self-contained; does not block on the fan-out board or Android parity.
- **Coordination:** touches two shared files — `src/types.ts` (frozen contract; the `Config.webtermToken` add is the coordination point) and `src/server.ts` (shared wiring). If run in parallel with the other Wave-5 tasks, use `isolation: worktree` and land the `types.ts` bump first so the server change type-checks. `PROGRESS_LOG.md` is orchestrator-written.
- **Cross-cutting risk to watch:** the CSP `script-src 'self'` constraint (`:310`) forces the login page to be JS-free (native form) — easy to get wrong by reaching for inline `<script>`; the integration test `GET /login → 200` plus a manual load catches it.

View File

@@ -0,0 +1,153 @@
# Android Projects / Diff / Worktree screens (client parity)
> **Feature id:** `w5-android-parity` · **Branch:** `develop` · **Server changes:** ZERO (every route below already ships from Wave 1-4).
## Reality check (read before planning — the task's premise is stale)
The task brief says the Android UI modules are "SDK-gated / commented out in `settings.gradle.kts`". **That is no longer true on `develop`.** Verified on disk:
- `android/settings.gradle.kts:50-53` **includes** `:app`, `:terminal-view`, `:host-registry`, `:client-tls-android` (uncommented). The `// TODO(android-sdk)` gating the README (`android/README.md:20-27`) still describes is stale doc drift.
- The SDK **is installed** and proven: `android/local.properties``sdk.dir=/usr/local/share/android-commandlinetools`; `platforms/{android-35,android-36}` present. `PROGRESS_ANDROID.md:358-382` records a full green gate (`./gradlew test :app:assembleDebug koverVerify` → ~484 tests, APK builds). The whole Android client (A1A36) is committed (`e254918`).
- `:api-client` is **not** empty (only the `.gitkeep` placeholders remain beside real code): `models/Projects.kt`, `routes/{ApiClient,Endpoints,ApiRoute}.kt` etc. exist.
**So the actual parity gap is not "bring modules online" — it is: the shipped Android client was built to iOS P0+P1, which *deliberately excluded* the git-write surface.** `ANDROID_CLIENT_PLAN.md:79` — "*worktree-create — server has routes the iOS client does not consume; out of parity scope*"; `:233` lists `POST /projects/worktree` among routes "explicitly NOT consumed"; `:218` scopes diff to `staged=1|0` only (no `?base=`); `:221-227` lists only **3** guarded routes (kill / hook.decision / put-prefs). Everything Wave 1-4 added on the web/server is missing on Android:
| Server route (exists) | src/server.ts | Android today | Gap |
|---|---|---|---|
| `GET /projects/diff?…&base=<rev>` | 811-837 | `:app` `HttpDiffFetcher` sends `staged` only (`DiffViewModel.kt:313-322`) | **base-diff** |
| `GET /projects/log?path=&n=` | 843-861 | — | **recent commits** |
| `GET /projects/pr?path=` | 871-887 | — | **PR + CI chip** |
| `POST /projects/worktree` | 908-934 | worktrees shown **read-only** (`ProjectDetailScreen.kt:116-159`) | **create** |
| `DELETE /projects/worktree` | 937-964 | — | **remove** |
| `POST /projects/worktree/prune` | 967-986 | — | **prune** |
| `POST /projects/git/{stage,commit,push}` | 997-1096 | Diff is inert read-only | **stage/commit/push** |
| `GET /projects` sync fields `ahead/behind/lastCommitMs` | types.ts:337-339 | `ProjectInfo` (Android) lacks them | **sync chip** |
This plan closes exactly that gap. It is the largest Wave 5 item but **narrower than "build the client"** — the foundation (grouping, prefs round-trip, detail page, diff flatten/render, adaptive nav, mTLS transport, design system) is done and reused verbatim.
---
## Contract (routes / messages / env / types)
No new server routes, no env vars, no `:wire-protocol` (frozen) changes. All additions are Kotlin client code.
### Route home decision (security-driven — do not deviate)
The Origin header (CSWSH 铁律) is stamped in **exactly one place**: `ApiRoute.toHttpRequest()` when `originPolicy == GUARDED` (`android/api-client/.../routes/ApiRoute.kt:61-71`; ARCHITECTURE §4.3, checklist `:467`). Therefore **every state-changing route MUST be added to `:api-client`** (worktree create/remove/prune, git stage/commit/push) so it flows through that single Origin-stamping point. The RO additions (`/projects/pr`, `/projects/log`) also go in `:api-client` for consistency with the existing RO methods (`ApiClient.kt:34-96`). The one exception is **diff-vs-base**: the diff route already lives self-contained in `:app` (`DiffViewModel.kt` — A24 put it there because it is read-only and needs no Origin), so extend it in place rather than churning it into `:api-client`.
### New `:api-client` route builders — `routes/Endpoints.kt` (mirror the existing 26-93)
```
// RO (no Origin)
fun projectPr(path: String): ApiRoute // GET /projects/pr?path=<enc> READ_ONLY
fun projectLog(path: String, n: Int?): ApiRoute // GET /projects/log?path=<enc>[&n=<int>] READ_ONLY
// G (Origin byte-equal) — bodies are application/json, encoded via ModelJson
fun createWorktree(path, branch, base: String?): ApiRoute // POST /projects/worktree
fun removeWorktree(path, worktreePath, force: Boolean): ApiRoute // DELETE /projects/worktree (BODY on DELETE — see Edge cases)
fun pruneWorktrees(path): ApiRoute // POST /projects/worktree/prune
fun gitStage(path, files: List<String>, stage: Boolean): ApiRoute // POST /projects/git/stage
fun gitCommit(path, message: String): ApiRoute // POST /projects/git/commit
fun gitPush(path): ApiRoute // POST /projects/git/push
```
Reuse the existing `percentEncode` (strict RFC 3986 unreserved, `Endpoints.kt:102-120`) for `path`/`n` query values and `ModelJson.encodeToString` for bodies. `n` clamps client-side to `1..GIT_LOG_MAX` mirror (server re-clamps; send it raw-but-bounded).
### New `:api-client` models (`models/`, all `@Serializable`, tolerant decode via `ModelJson` / `LossyDecode`, mirror types.ts)
- `PrStatus` + `PrAvailability` (string-union → enum with unknown→`ERROR`) + `PrCheckSummary` — types.ts:560-588. **Every field except `availability` optional**; `availability` unknown/missing → `ERROR` (never throw).
- `CommitLogEntry { hash, at: Long, subject }` + `GitLogResult { commits, truncated }` — types.ts:695-704 (list-lossy: drop malformed commit, keep rest).
- `GitWriteOutcome` — a **client result union** for the guarded git ops carrying the server's *safe* body: `Ok(payload)` vs `Rejected(status, message)`. Server always returns `{ ok, … }` on 200 and `{ ok:false, error:"<safe string>" }` / `{ error }` on failure (git-ops.ts:81-119, worktrees.ts:201-341 — never raw stderr). Decode `error` as an **inert** string to display.
- stage 200: `{ ok, staged: Boolean, count: Int }`
- commit 200: `{ ok, commit: String }` (short sha; may be `""`)
- push 200: `{ ok, branch, remote }`
- worktree create 200: `{ ok, path, branch }`; remove 200: `{ ok, path }`; prune 200: `{ ok, pruned: List<String> }`
- Extend `models/Projects.kt` `ProjectInfo` with `ahead: Int?`, `behind: Int?`, `lastCommitMs: Long?` (types.ts:337-339). Extend the `:app` `DiffResult` (`DiffViewModel.kt:215`) with `base: String? = null` (types.ts:553).
### New `ApiClient` methods (`routes/ApiClient.kt`) + status mapping
- `suspend fun projectPr(path): PrStatus` — 200→tolerant decode; 400→`ProjectPathInvalid`; 404→`ProjectNotFound`; else `UnexpectedStatus`. (PR *degrade* — gh missing/unauth/no-PR — is `availability` inside a **200** body, not an HTTP status.)
- `suspend fun projectLog(path, n): GitLogResult` — 200→decode; 400→`ProjectPathInvalid`; 404→`ProjectNotFound`; 500→`ProjectDetailUnavailable`(reuse) or new `GitLogUnavailable`.
- Guarded ops return `GitWriteOutcome`: 200→`Ok(...)`; **403→`Rejected` reading body.error** (disabled *and* Origin-fail both 403 — see Security); 400/404/409→`Rejected(status, body.error)`; 429→`RateLimited`; else `UnexpectedStatus`. New `ApiClientError` cases as needed: `WorktreeDisabled`, `GitOpsDisabled` are **not** distinguishable from Origin-403 by status alone → prefer surfacing `Rejected(status, safeMessage)` over inventing typed variants (`ApiClientError.kt:11-48` is the sealed set to extend minimally).
### `:app` presenters (plain, JVM-testable — same pattern as `DiffViewModel`/`ProjectDetailViewModel`, NOT `androidx.lifecycle.ViewModel`)
- `WorktreeViewModel(gateway, repoPath)`: `create(branch, base?)`, `remove(worktreePath, force)`, `prune()`. Each is a phase machine (`Idle/Working/Done/Failed(message)`) that **re-fetches project detail** on success so the worktree list refreshes. Branch name validated client-side (mirror `validateBranchName`, worktrees.ts:95) before any I/O.
- Extend `ProjectsGateway` (`ProjectsViewModel.kt:400-413`) with the new gateway methods; `ApiClientProjectsGateway` delegates to the new `ApiClient` methods. (Keeps the VM JVM-tested against a fake.)
- Extend `DiffViewModel` (`DiffViewModel.kt:47`): add `base: String?` state + `setBase(rev)`; when `base != null` the `staged` toggle is suppressed (server ignores `staged` when `base` set — server.ts:831 / public/diff.ts:112-129). Extend `diffUrl` (`:313`) to append `&base=<enc>` and **omit `staged`** in base mode. Add stage/commit/push: `toggleStage(file, staged)`, `commit(message)`, `push()` via the guarded gateway; these are **only offered in working/staged mode**, never base mode (parity with public/diff.ts:321).
- `PrChipState` (fold into `ProjectDetailViewModel` or a small `PrViewModel`): fetch `/projects/pr` lazily on detail load; render one chip.
---
## Files to change
| Path | Concrete change |
|---|---|
| `android/api-client/.../routes/Endpoints.kt` | Add 8 builders (pr, log, worktree×3, git×3). RO ones `READ_ONLY`; write ones `GUARDED` with `ModelJson` bodies. Reuse existing `percentEncode`/`HEX`. |
| `android/api-client/.../routes/ApiClient.kt` | Add `projectPr`, `projectLog`, `createWorktree`, `removeWorktree`, `pruneWorktrees`, `gitStage`, `gitCommit`, `gitPush` with the status mapping above. |
| `android/api-client/.../routes/ApiClientError.kt` | Add minimal cases only if needed (`GitLogUnavailable`); prefer `Rejected(status,msg)` carried in `GitWriteOutcome` over per-error variants. |
| `android/api-client/.../models/PrStatus.kt` (new) | `PrStatus`/`PrAvailability`/`PrCheckSummary` tolerant. |
| `android/api-client/.../models/GitLog.kt` (new) | `CommitLogEntry`/`GitLogResult` list-lossy. |
| `android/api-client/.../models/GitWrite.kt` (new) | `GitWriteOutcome` + per-op payloads; serializers registered in `models/Serializers.kt` if custom. |
| `android/api-client/.../models/Projects.kt` | Add `ahead`/`behind`/`lastCommitMs` to `ProjectInfo`. |
| `android/app/.../viewmodels/WorktreeViewModel.kt` (new) | Create/remove/prune phase machine; branch validation; re-fetch detail on success. |
| `android/app/.../viewmodels/ProjectsViewModel.kt` | Extend `ProjectsGateway` + `ApiClientProjectsGateway` with the new methods; add sync-chip copy to `ProjectsCopy`. |
| `android/app/.../viewmodels/ProjectDetailViewModel.kt` | Compose in worktree actions + PR chip fetch + recent-commits fetch (each independent, failure-isolated). |
| `android/app/.../viewmodels/DiffViewModel.kt` | Add `base` mode (+`setBase`), extend `diffUrl` with `&base=`, add `toggleStage/commit/push` via a guarded `GitWriteGateway`; extend `DiffResult` with `base`. |
| `android/app/.../screens/ProjectDetailScreen.kt` | Add: "New worktree" branch-input sheet (+optional base), per-worktree **remove** (force-confirm dialog, block `isMain`), **prune** button, a **PR chip** (tappable only if `url` parses `https`), a **recent commits** section. Keep all server strings inert `Text` (§8). |
| `android/app/.../screens/DiffScreen.kt` | Add a base-rev input (a third mode beside Working/Staged), per-file **Stage/Unstage** buttons in working/staged only, a commit-message field + **Commit**/**Push** buttons with result banners. |
| `android/app/.../screens/ProjectsScreen.kt` | Optional: render sync chip (ahead/behind) on project cards from the new `ProjectInfo` fields. |
| `android/app/.../nav/NavGraph.kt` | The diff/detail routes already exist (`:94-131`); no new route needed — worktree/commit UIs are in-place sheets/dialogs, not new destinations. Verify `ProjectDetailPane`/`DiffPane` pass the new gateway. |
| `android/app/.../designsystem/*` | Reuse existing tokens (`WebTermColors.statusWorking/statusStuck`, `WebTermCard`, `Spacing`). PR-check pass/fail/pending → existing status colors. No new tokens. |
| test files (see TDD) | New/extended JVM unit tests under `api-client/src/test/...` and `app/src/test/.../viewmodels/`. |
| `android/README.md` | Fix the stale "SDK-gated / COMMENTED OUT" section (`:12-27`) to match reality. |
| `docs/PROGRESS_LOG.md` | Orchestrator appends the W5 entry (not the builder). |
---
## TDD steps (ordered — JUnit5 + `runTest`/`StandardTestDispatcher` + Turbine + MockK, matching `DiffViewModelTest`/`ApiRouteShapeTest`)
Write test → RED → implement → GREEN, bottom-up (pure `:api-client` first, then `:app` presenters, Compose deferred to device QA per plan §7).
**Phase A — `:api-client` routes + models (fully green + Kover ≥80% in one pass)**
1. `models/PrStatusTest.kt` — decode a full `availability:"ok"` body; unknown `availability``ERROR`; missing fields tolerated; `PrCheckSummary` counts; non-object → error/`availability=ERROR` (mirror `HappyPathDecodeTest`/`TolerantDecodeTest`).
2. `models/GitLogTest.kt` — decode `{commits,truncated}`; drop a commit missing `hash`/`at`, keep siblings; `truncated` passthrough.
3. `models/GitWriteTest.kt` — decode each 200 payload; decode failure body `{ok:false,error:"…"}``Rejected(status,message)`.
4. `routes/GitRouteShapeTest.kt` (extend `ApiRouteShapeTest`) — **Origin-iff-guarded**: `projectPr`/`projectLog` carry **no** Origin; worktree/git-write carry `Origin == endpoint.originHeader` byte-equal; assert method+path+query encoding (`?path=` strict-encoded; `&n=`); assert `DELETE /projects/worktree` **carries a JSON body**.
5. `routes/ApiClientGitTest.kt` — with `FakeHttpTransport` queue canned responses: pr 200/400/404; log mapping; each guarded op 200→`Ok`, 403→`Rejected(body.error)`, 429→`RateLimited`, 409→`Rejected`. Assert the fake **received** the Origin header on writes and **not** on reads.
6. Run `./gradlew :api-client:test :api-client:koverVerify` → green.
**Phase B — worktree + diff-base presenters (`:app`, green)**
7. `viewmodels/WorktreeViewModelTest.kt` — invalid branch name → `Failed` with no I/O; create success → `Done` + detail re-fetch invoked; remove of `isMain` blocked; force flag threaded; 403-disabled → `Failed(safeMessage)`; 429 → rate-limited copy.
8. Extend `DiffViewModelTest.kt``setBase("main")` builds `…/projects/diff?path=…&base=main` with **no** `staged`; base mode hides the staged toggle; `diffUrl` percent-encodes `base`; base-mode disables stage/commit/push.
**Phase C — git-write from diff + PR/log (`:app`, green)**
9. Extend `DiffViewModelTest.kt``toggleStage(file,true)` posts `{path,files:[newPath],stage:true}` and refreshes; `commit("msg")``Ok(sha)` banner; empty message rejected client-side or surfaces server 400 message; `push()``Ok(branch,remote)`; 409 push-rejected → inert server message.
10. `viewmodels/ProjectDetailPrLogTest.kt` — PR fetch failure does **not** fail the detail load (isolated); `availability != ok` renders degraded copy; recent-commits list decodes + isolates its own error.
11. Run `./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify` → green.
**Device QA (deferred — no emulator/Firebase/host here; append to `android/DEVICE_QA_CHECKLIST.md`):** worktree-create sheet + list refresh, remove force-confirm dialog, Stage/Unstage button layout, commit/push banners, PR chip tap→browser (https-only), base-rev input, sync-chip rendering, adaptive-pane behavior.
---
## Edge cases
- **DELETE with a request body.** `DELETE /projects/worktree` reads `req.body` (server.ts:943-945), but the current Android transport has only tested body-less DELETE (`killSession`). **Verify `OkHttpHttpTransport` actually transmits a body on DELETE** (some stacks drop it) — add a transport-level test if not covered. This is the single highest-risk integration gotcha.
- **403 is overloaded.** `requireAllowedOrigin` failure *and* `worktreeEnabled==false`/`gitOpsEnabled==false` both return **403** (server.ts:909-911, 938-939, 998-1000). The client cannot tell them apart by status → must decode `body.error` and surface it inertly; do **not** assume 403 means "re-pair / bad Origin".
- **No capability-discovery endpoint.** `GET /config/ui` exposes only `allowAutoMode`/`costBudgetUsd` (server.ts:1099-1106) — it does **not** report `worktreeEnabled`/`gitOpsEnabled`/`GH_ENABLED`. So the client discovers "disabled" only via a 403 at action time; after a disabled-403, hide/disable that control for the session.
- **PR degrade lives in the body, not the status.** `gh` missing/unauth/no-PR all return **200** with `availability∈{not-installed,unauthenticated,no-pr,disabled,error}` (types.ts:560-566). Render one chip from `availability`; never treat non-`ok` as an HTTP error.
- **`staged` vs `base` precedence.** When `base` is set the server ignores `staged` (server.ts:831); mirror by omitting `staged` and hiding the toggle in base mode (public/diff.ts:112-129).
- **Rate limits (429).** stage/commit share `gitWriteLimiter`; push has a tighter `gitPushLimiter` (server.ts:1003, 1075). Surface `RateLimited` copy; do not auto-retry.
- **Empty commit sha.** `git commit` may return `{ok:true, commit:""}` (git-ops.ts:252) — render "committed" without a sha, don't crash on empty.
- **Base-rev injection.** `?base=` must be inert and pass the server's `isPlausibleRev` (server.ts:826); the client sends it percent-encoded and lets the server 400 junk — surface that 400's message.
- **Detail refresh race.** A worktree create/remove followed by a detail re-fetch can race a concurrent poll; cancel the in-flight fetch (the `job?.cancel()` pattern in `DiffViewModel.kt:78-82`).
- **Force-remove of dirty worktree.** Server 409 "uncommitted changes — force required" (worktrees.ts:278) → the remove dialog must offer an explicit **Force** re-confirm, and must block `isMain` client-side (worktrees.ts:328).
- **Tolerant decode everywhere.** A partial/malformed PR/log/diff body must degrade (drop-one-keep-rest), never throw — reuse `LossyDecode`/`ModelJson` discipline (`ApiClient.kt:26-28`).
## Security
- **Origin 铁律 preserved.** All six write routes go through `ApiRoute.toHttpRequest` GUARDED stamping (`ApiRoute.kt:61-71`) — the single CSWSH defense (ANDROID_CLIENT_PLAN `:467`). The two new reads (pr/log) MUST NOT carry Origin. A route-shape test asserts both directions (test step 4) so a future reclassification goes red, not silently wrong.
- **Untrusted server strings stay inert.** Branch names, worktree paths, commit subjects, PR titles, diff lines, `error` messages are rendered as plain Compose `Text` — no `linkify`/Markdown/`AnnotatedString` autolink (ANDROID_CLIENT_PLAN `:476`, plan §8). The **PR chip is the one exception**: a tappable link **only when `url` parses as `https`** — validate scheme before making it clickable (`:476`).
- **No credential leakage / no cache.** All calls ride the shared mTLS `OkHttpClient` with `.cache(null)` (`:470`) — diff/commit bodies can contain secrets; never persist them.
- **Client-side pre-validation (defense in depth).** Branch name (`validateBranchName` mirror), non-empty commit message, `isAbsoluteCwd` for any path minted into an action (`ProjectsViewModel.kt:139-143` precedent). The server re-validates + realpath-contains regardless (git-ops.ts / worktrees.ts) — the client checks are UX, not the security boundary.
- **Never log** worktree paths, commit messages, PR titles, or error bodies.
## Effort & dependencies
- **Effort: L (largest Wave 5 item), ~7-9 pd** for the buildable+tested layer — *below* the brief's 8-12 pd because the client scaffold, grouping, prefs round-trip, diff flatten/render, adaptive nav, mTLS transport and design system already exist and are reused verbatim (the app already builds to an APK). The remaining work is ~8 REST routes + ~6 models + 3-4 presenters + ~4 screen edits + ~40-50 tests. Add device-QA time separately (not doable in this env).
- **Dependencies:** none external — every server route shipped in Wave 1-4 (server.ts:811-1096), so this is a pure client pass with **zero server change**. Depends only on the existing frozen `:wire-protocol` `HostEndpoint`/`HttpTransport` and `:api-client` (both stable). `develop` must contain the Android tree from `e254918` (it does).
- **Can one builder pass get a full green Gradle build? Yes for the logic+compile layer, but phase it into 3 green checkpoints — do not attempt it monolithically.** A single pass can realistically finish **Phase A (api-client)** entirely green with Kover ≥80%. Phases B and C compile (`:app:assembleDebug`) and pass JVM unit tests, but their Compose surfaces (sheets, dialogs, buttons, chip, banners) are **device-QA-deferred by definition** (plan §7 — no emulator/Firebase/host in this env), exactly as every prior Android wave deferred rendering. So a realistic single-builder deliverable is: **`./gradlew test :app:assembleDebug koverVerify` green**, with the interactive git-write flows (create a worktree, stage→commit→push from a phone, tap a PR chip) recorded in `DEVICE_QA_CHECKLIST.md` rather than proven here. Recommended phasing: A (api-client routes/models/tests) → B (worktree + diff-base presenters) → C (git-write-from-diff + PR/log + screen wiring), each ending on a green build so review and rollback stay cheap. The stage→commit→push-from-the-diff-viewer flow (Phase C) is the largest and riskiest single chunk; keep it last and isolated.
- **First action for the builder:** run `cd android && ./gradlew test :app:assembleDebug` to confirm the baseline is green on `develop` before adding anything (the toolchain is proven but unverified on this exact checkout), and fix the stale `README.md` no-SDK section as part of the pass.

View File

@@ -0,0 +1,157 @@
# Worktree fan-out board (parallel agent lanes)
**Feature id:** `w5-fanout-board` · **Branch base:** `develop` · **Effort: L** · Depends on Wave 2 (PTY-inject / `initialInput`) + Wave 4 (worktree create + remove).
Fan **one task** across **N** branch/agent lanes of one repo: create N worktrees, spawn N Claude sessions each pre-injected with the same prompt, watch them race side-by-side in the existing split-grid board, approve/kill per lane, then **keep the winner** (its tab + worktree stay) while **losers get worktree-remove**. This is **≈90% composition of shipped parts**; the only genuinely new code is a small pure launch-command builder, an in-memory (localStorage-persisted) lane-group model, and one thin read-only grouping endpoint.
> **Byte-shuttle + no-clobber preserved:** the server never learns "fan-out" semantics on the terminal stream. Each lane is its **own worktree → own PTY** (`createWorktree` at `src/http/worktrees.ts:224` gives a fresh dir; `attach(null)` spawns a fresh PTY there), so two Claudes editing the same repo never touch the same working tree.
## New vs reused
| Concern | Reused (no change) | New (thin) |
|---|---|---|
| Spawn N worktrees | `createWorktree` (`worktrees.ts:224`) via `POST /projects/worktree` (`server.ts:908`) | orchestrator loops the route N× (sequential) |
| Spawn N sessions w/ prompt | `openProject``addEntry``initialInput` (`tabs.ts:836,704,717`) → typed after `attached` (`terminal-session.ts:374`, `INITIAL_INPUT_DELAY_MS=700` `:30`); `claude "<prompt>"` pattern proven at `projects.ts:958` | `buildFanoutCmd(prompt,mode)` — pure, shell-quotes the prompt |
| Watch board | `setGridLayout('grid-4'\|'grid-6')` (`tabs.ts:1020`), per-quadrant approve/maximize/monitor (`renderInlineApprove`/`toggleMaximize`/`toggleMonitor` `tabs.ts:1364,1081,1088`), statusLine gauges (`renderCell` `:1328`, `tab-gauge` `:1451`) | per-cell "🏆 Keep" button (same pattern as `cell-max` wiring `tabs.ts:776-801`) |
| Discard losers | `removeWorktreeReq`/`confirmAndRemoveWorktree` (`projects.ts:299,349`) → `DELETE /projects/worktree` (`server.ts:937`) | batch-confirm loop in `keepFanoutWinner` |
| Grouped discovery | `manager.list()`/`GET /live-sessions` (`manager.ts:185`, `server.ts:332`); `LiveSessionInfo.cwd` (`types.ts:299`) | `GET /live-sessions/grouped` + pure `groupSessionsByRepo` |
**Merge of the winner:** the winner's session/worktree **stays open** so the user runs the merge inside it (the ROADMAP + task design collapse "keep winner" to "winner session stays"). A one-click merge is **out of scope for v1** (needs conflict handling); an optional thin `merge()` in `git-ops.ts` is sketched under Effort as a follow-up.
---
## Contract
### New HTTP route (`src/server.ts`) — the one thin server piece
| Method / path | Guard | Response |
|---|---|---|
| `GET /live-sessions/grouped` | none (read-only aggregate, same threat model as `/live-sessions` `:332` and `/digest` `:340`) | `200 SessionGroup[]` |
Pure read over `manager.list()`; **no** Origin guard, **no** new write path. Grouping is **string-only** (no `git` exec): fan-out worktrees always live under `<repo>-worktrees/` (createWorktree base, `worktrees.ts:151-152`), so sessions whose `cwd` shares that parent cluster into one group. Registered beside `/live-sessions` (`server.ts:332`).
### `src/types.ts` (coordination edit — the frozen shared contract)
```ts
// group of running sessions sharing a repo/worktree-root (fan-out discovery)
export interface SessionGroup {
repoRoot: string; // derived repo dir (parent of the *-worktrees folder, or the cwd)
label: string; // basename(repoRoot)
sessions: LiveSessionInfo[]; // members, newest-first (already the manager.list order)
}
// launch options the FE passes to TabApp.launchFanout (FE-internal, but typed shared)
export interface FanoutLaunchOpts {
prompt: string;
lanes: number; // 2..maxFanoutLanes
branchBase: string; // slug; lanes get `${branchBase}-lane-${i}`
mode?: PermissionMode; // reuse types.ts:445
}
```
Add `maxFanoutLanes?: number` to **`UiConfig`** (`types.ts:656`) so the FE stepper max is server-controlled.
### `src/config.ts` env var (additive, optional)
| Env | Field (add to `Config` `types.ts:26` block) | Default | Parser |
|---|---|---|---|
| `MAX_FANOUT_LANES` | `maxFanoutLanes: number` | `6` (matches `grid-6` capacity, `grid-layout.ts:30`) | `parseNonNegativeInt` (`config.ts:83`, as `maxSessions` `:297`) |
Effective N is `min(opts.lanes, cfg.maxFanoutLanes, 6, maxSessions liveCount)` — bounded by grid capacity and the DoS cap (`assertUnderSessionCap`, `manager.ts:106`).
### Client-side contract
- **No new `ClientMessage`/`ServerMessage`.** The stream stays a byte-shuttle. Launch = N existing `POST /projects/worktree` + N existing WS attaches (via `openProject`). Discard = existing `DELETE /projects/worktree`.
- **New `ProjectsHooks` method** (`projects.ts:39`): `onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => void` — mirrors the existing `onOpenProject` hook exactly; wired in `tabs.ts` constructor (`:180`) to `this.launchFanout(...)`.
- **New pure launch-cmd builder** `buildFanoutCmd(prompt, mode, allowAutoMode): string``claude [--permission-mode <m>] '<shell-quoted, newline-collapsed prompt>'\r`. Single-quote wrap with `'``'\''` escaping; collapse `\r?\n`→space; cap length (`FANOUT_PROMPT_MAX = 4000`). Reuses `resolveMode`/`buildClaudeCmd` shape (`tabs.ts:659,664`).
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `SessionGroup`, `FanoutLaunchOpts`; add `maxFanoutLanes?: number` to `UiConfig` (`:656`); add `maxFanoutLanes: number` to `Config` (`:26` block). |
| `src/config.ts` | Parse `MAX_FANOUT_LANES` in `loadConfig` (helper exists, `parseNonNegativeInt` `:83`); include in returned `Config`. |
| `src/http/session-groups.ts` **(new)** | Pure `deriveRepoRoot(cwd: string \| null): string \| null` (strip a trailing `/<name>` when the parent basename ends with `-worktrees`, else return cwd) + `groupSessionsByRepo(sessions: LiveSessionInfo[]): SessionGroup[]`. No I/O — fully node-unit-testable. High-cohesion small file (per coding-style). |
| `src/server.ts` | Register `GET /live-sessions/grouped``res.json(groupSessionsByRepo(manager.list()))` beside `:332`; import `groupSessionsByRepo`; add `maxFanoutLanes` to the `UiConfig` object at `/config/ui` (`:1099`). |
| `public/fanout.ts` **(new)** | `buildFanoutCmd(prompt, mode, allowAutoMode)`, `shellSingleQuote(s)`, `sanitizePrompt(s)` (collapse newlines, trim, cap), `laneBranch(base, i)`, `slugify(prompt)` — all pure/exported for jsdom unit tests. Plus `createWorktreeReq(repoPath, branch)` thin POST helper if not reusing the inline one in `projects.ts:725` (extract it to reuse — see below). |
| `public/projects.ts` | Add `onFanout` to `ProjectsHooks` (`:39`); add `renderFanoutForm(detail, hooks)` (sibling of `renderNewWorktreeForm` `:692`: prompt `<textarea>`, N `<input type=number min=2 max=maxFanoutLanes>`, branch-base `<input>` prefilled `slugify(prompt)`, permission-mode `<select>`, "⑃ Fan out N lanes" submit) rendered in `renderProjectDetail` (`:822`) just after the New Worktree form (`:930`); **extract** the inline `POST /projects/worktree` body (`:725-737`) into an exported `createWorktreeReq(repoPath, branch)` so fan-out and the form share it (DRY). |
| `public/tabs.ts` | Add `launchFanout(repoPath, repoName, opts)` + `keepFanoutWinner(groupId, winnerSessionId)`; a `fanoutGroups: Map<string, FanoutGroup>` field (persisted via a new `FANOUT_KEY`); extend `TabEntry` with `fanoutGroupId?: string`, `worktreePath?: string`, `branch?: string`; append a per-cell **🏆 Keep** button in `addEntry` (`:776-801`) shown only when `entry.fanoutGroupId` is set (toggled in `renderCell` `:1328`); wire `onFanout` in the constructor (`:180`). Reuse `setGridLayout` (`:1020`), `addEntry` (`:704`), `countOpenWithTitlePrefix` (`:849`), `removeWorktreeReq` (`projects.ts:299`). |
| `public/styles.css` (or the FE CSS entry) | `.proj-fanout-form`, `.cell-keep` (mirror `.cell-max` `tabs.ts:779`), `.fanout-banner`. No new layout — reuses `lay-grid-4/6` + `term-cell` chrome. |
`launchFanout` algorithm (FE orchestration, **sequential**`git worktree add` takes a repo lock, so parallel adds race):
1. Clamp `N = min(opts.lanes, maxFanoutLanes, 6)`; validate `branchBase` via `validateBranchNameClient` (`projects.ts:672`); build `cmd = buildFanoutCmd(prompt, mode, allowAutoMode)` once.
2. `for i in 1..N`: `const r = await createWorktreeReq(repoPath, laneBranch(base,i))`; on `r.ok` push `{branch, worktreePath:r.path}` to the group; on failure record the error, **continue** (partial-success, surfaced in the banner — no auto-rollback in v1).
3. If ≥1 lane created: create a `FanoutGroup {id, repoPath, repoName, prompt, lanes[]}`; for each created lane `addEntry(null, `${repoName}·${branch}`, worktreePath, cmd)` (reuse `openProject`'s exact addEntry call shape `:842`), tag the returned `TabEntry` with `fanoutGroupId/worktreePath/branch`; persist.
4. `setGridLayout(N <= 4 ? 'grid-4' : 'grid-6')` and `activate` the first lane. The board's existing per-quadrant approve/maximize/monitor + gauges now cover every lane for free.
`keepFanoutWinner(groupId, winnerSessionId)`:
1. Resolve the group; single confirm: `"Keep <winner branch> and discard the other N1 lanes (delete their worktrees)?"`.
2. For each losing lane: `closeTab(idx)` (detach — PTY reaped by IDLE_TTL) then `removeWorktreeReq(repoPath, lane.worktreePath, false)`; on `409` dirty → `removeWorktreeReq(..., true)` (already inside the batch confirm — no per-lane prompt). Collect failures into the banner.
3. Winner tab **stays**; drop the group from `fanoutGroups`; if all losers gone, `setGridLayout('single')` and activate the winner.
---
## TDD steps (ordered — RED→GREEN, matching repo style)
**Backend pure — `test/http/session-groups.test.ts`** (new, node env, no I/O; mirror the pure-helper style of `test/http/worktrees*.test.ts`):
1. `deriveRepoRoot('/a/proj-worktrees/lane-1')``/a/proj` (parent basename ends `-worktrees`). `deriveRepoRoot('/a/proj')``/a/proj`. `deriveRepoRoot(null)``null`. → implement.
2. `groupSessionsByRepo([...])`: two sessions with cwds `/a/proj-worktrees/lane-1` and `/lane-2`**one** `SessionGroup` (`repoRoot:/a/proj`, 2 members); an unrelated `/b/other` cwd → its own group; `cwd:null` → skipped or an "ungrouped" bucket (decide + assert). Members preserve `manager.list` newest-first order.
**Backend config — `test/config.test.ts`** (extend): assert `maxFanoutLanes` default `6`, `MAX_FANOUT_LANES=3` override parses, `-1` throws (fail-fast, like the `MAX_SESSIONS` test). Update **every** all-fields `CFG` fixture (`grep -rl "maxSessions" test/`) to add `maxFanoutLanes` (compile gate).
**Backend route — `test/integration/*.test.ts`** (extend an existing live-sessions integration file; reuse `startServer` + `fetch`, `itPty` where a real session is needed):
3. `GET /live-sessions/grouped` on an empty manager → `200 []`. No Origin header required (read-only) — assert it does **not** 403.
4. `itPty`: attach two sessions with cwds under one `*-worktrees` dir → `grouped` returns one group with both ids. `GET /config/ui` includes `maxFanoutLanes`.
**FE pure — `test/fanout.test.ts`** (new, jsdom or node; pure functions):
5. `shellSingleQuote("it's ok")``'it'\''s ok'`. `sanitizePrompt("a\nb\r\nc")``"a b c"`; over-length → truncated to `FANOUT_PROMPT_MAX`.
6. `buildFanoutCmd("fix bug","plan",true)``claude --permission-mode plan 'fix bug'\r`; `mode:'default'``claude 'fix bug'\r`; `mode:'auto', allowAutoMode:false` → downgraded to no `--permission-mode` (SEC-M5 parity with `resolveMode` `tabs.ts:659`).
7. `laneBranch('feat-x',3)``feat-x-lane-3`; result passes `validateBranchNameClient` (`projects.ts:672`).
**FE component — `test/projects.test.ts`** (extend; reuse `makeDetail`/`makeHooks`, stubbed `fetch`):
8. `renderFanoutForm` renders a prompt textarea, N stepper (max = `maxFanoutLanes`), branch-base input, mode select, submit. Empty prompt → submit disabled / inline error via `textContent` (SEC-L3/H6, like `renderNewWorktreeForm` `:708`).
9. Submitting calls `hooks.onFanout(detail.path, detail.name, {prompt, lanes, branchBase, mode})`.
**FE orchestration — `test/tabs.test.ts`** (extend; the file already stubs `TerminalSession`/`fetch`):
10. `launchFanout(repo,'repo',{lanes:3,...})` with `fetch` stubbed to return `{ok:true,path:'/wt/lane-i'}`**3** `addEntry` calls (assert 3 tabs), each with the **same** `initialInput` (`buildFanoutCmd` output) and its lane cwd; grid becomes `grid-4`; worktree POSTs happen **sequentially** (assert call order / that the (k+1)th starts after the kth resolves).
11. Partial failure: 2nd `createWorktreeReq` rejects → 2 lanes created, banner shows the failure, no throw (never-throw discipline, `projects.ts:299`).
12. `keepFanoutWinner(id, winnerId)`: `confirm=()=>true`, `fetch` `{ok:true}` → loser tabs closed (assert `tabs.length` drops to 1), `DELETE /projects/worktree` called once per loser with the loser's `worktreePath`; winner tab remains; layout → `single`.
13. Dirty loser: `DELETE` first `{ok:false,status:409}` then `{ok:true}` → second call body has `force:true`; **no** second `window.confirm` (batch already confirmed).
14. `confirm=()=>false` → no `closeTab`, no `DELETE` (no accidental deletion).
Run `npm test`; the ~80% gate holds — grouping/config/route are deterministic node tests, and every FE branch (build-cmd modes, sequential launch, partial failure, keep-winner happy/dirty/cancel) is jsdom-exercised. The only PTY-real assertion (grouped over live sessions) is `itPty`-gated (auto-skips in sandbox, runs in CI).
---
## Edge cases
- **Prompt with quotes / `$` / backticks / `;`** — single-quote wrapping + `'\''` escaping means the shell passes it verbatim to `claude` as one argv element; no command injection into the shell (see Security).
- **Multi-line prompt** — a raw newline typed into the PTY submits early; `sanitizePrompt` collapses `\r?\n`→space (single-line task descriptions are the use case, like an issue title). Documented in the form's helper text.
- **N exceeds caps** — clamped to `min(maxFanoutLanes, 6, maxSessionsliveCount)`; if the DoS cap is hit mid-launch, `addEntry`→attach throws server-side via `assertUnderSessionCap` (`manager.ts:106`, the M4 path) and that lane shows an `exit(-1)`; the banner reports "started K of N".
- **Branch already checked out / dir exists** — `createWorktree` returns `409` (`worktrees.ts:200-208`); that lane is skipped with a banner note; other lanes proceed (each branch is `-lane-i`, so collisions only on a re-run — suggest a fresh `branchBase`).
- **Sequential lock contention** — awaiting each `createWorktree` before the next avoids git's `worktree add` lock race; total launch is O(N) git adds (bounded, N≤6).
- **Keep-winner on a dirty loser** — `DELETE` 409 → auto-retry with `force:true` *inside the single batch confirm* (the user already accepted "discard the other lanes"); still never force-removes the **main** worktree (server rejects `isMain` 400, `worktrees.ts:327`) — losers are never main.
- **Winner == current worktree you're viewing** — fine; only losers are removed. Removing a loser whose tab is elsewhere just detaches then deletes its dir.
- **Reload mid-race** — `fanoutGroups` persisted to `FANOUT_KEY` (like `TABS_KEY` `tabs.ts:57`, written in `onSessionId` once ids resolve); on boot, reconstruct the board from persisted groups. Fallback discovery: `GET /live-sessions/grouped` re-clusters live sessions by repo even if localStorage was cleared.
- **Session exits (Claude finished) before you pick a winner** — the exited lane keeps its last screen (L1 replay, `manager.ts:149`) and its gauge greys stale (`STATUSLINE_TTL_MS`, `tabs.ts:64`); still selectable as winner (its worktree persists until you keep someone).
- **`worktreeEnabled=0`** — create/remove routes 403 (`server.ts:910,939`); `launchFanout` surfaces the 403 in the banner and creates nothing (the form can hide itself when `/config/ui` signals disabled — optional).
---
## Security
- **No new trust boundary.** Launch reuses `POST /projects/worktree` and each lane's WS attach; discard reuses `DELETE /projects/worktree` — all already `requireAllowedOrigin`-guarded (`server.ts:480,909,938`). `GET /live-sessions/grouped` is read-only (session ids, cwds, statuses — same data already in `/live-sessions`), so no Origin guard, consistent with `/live-sessions` (`:332`) and `/digest` (`:340`).
- **Prompt → shell injection (the one new risk).** The prompt is typed as raw bytes into the lane's shell before `claude` parses it. `shellSingleQuote` wraps it in single quotes with `'``'\''` escaping so the shell treats it as a single literal argv element — no `$(...)`, backtick, `;`, `&&`, or redirection can execute. `sanitizePrompt` additionally strips newlines (which would submit a partial line) and caps length. **This is the load-bearing quoting invariant — unit-test it (steps 5-6) and never `String`-concat the prompt into the command unquoted.** (The threat is limited anyway: the caller already has full shell access via the terminal — this just avoids a *surprising* early-execution when a prompt contains shell metacharacters.)
- **Branch names** — validated client-side (`validateBranchNameClient` `:672`) **and** server-side (`validateBranchName` `worktrees.ts:95`, `-b <branch>` after `--`); flag-injection (`-`-leading) and traversal already rejected; the worktree dir is containment-checked (`computeWorktreeDir` M2, `:146`).
- **Destructive keep-winner** — a single explicit confirm before deleting N1 worktrees; `removeWorktree`'s realpath-must-match-a-registered-worktree spine (`worktrees.ts:313-323`) means only genuine linked worktrees can be deleted, never an arbitrary path, and never `main`. Errors render via `textContent` (SEC-L3/H6), never `innerHTML`.
- **DoS bounds** — N capped by `maxFanoutLanes`/grid-6/`maxSessions`; each lane is one bounded PTY; grouping endpoint does zero `git`/FS work (pure string grouping over the in-memory list).
- **Audit** — each `createWorktree`/`removeWorktree` already logs via `sanitizeForLog` (`server.ts:923,952`); no new logging path, prompt bytes are never logged.
---
## Effort & dependencies
- **Rough effort: L (~34 dev-days).** Backend is genuinely thin: config field + one pure grouping module + one read-only route (~0.5 d incl. tests). FE carries the weight: `public/fanout.ts` pure helpers (~0.5 d), `renderFanoutForm` + `onFanout` wiring (~0.5 d), `launchFanout`/`keepFanoutWinner`/lane-group model + persistence + the 🏆 cell button (~1.5 d), tests (~0.75 d).
- **Depends on (all shipped):** W2 `initialInput` (`terminal-session.ts:127,374`) + `openProject`/`addEntry` (`tabs.ts:836,704`); W4 `createWorktree` (`worktrees.ts:224`) + `removeWorktree` (`:301`) + `removeWorktreeReq`/`confirmAndRemoveWorktree` (`projects.ts:299,349`); the split-grid board (`grid-layout.ts`, `tabs.ts` `applyLayout`/`renderInlineApprove`/`renderCell`). **No** node-pty/protocol change, **no** new WS frame, **no** DB/migration, **no** new npm dep.
- **Coordination:** the `src/types.ts` edit (`SessionGroup`, `FanoutLaunchOpts`, `UiConfig.maxFanoutLanes`, `Config.maxFanoutLanes`) is the only cross-cutting change — freeze it first and update every all-fields `CFG` test fixture in the same commit (it touches `Config`).
- **Optional follow-ups (explicitly out of v1 scope):** a thin `merge(repoPath, branch, opts)` in `src/http/git-ops.ts` (sibling of `commit` `:218`/`push` `:312`, reusing `classifyGitError` `:81`) behind `POST /projects/git/merge` for one-click "land the winner"; server-side `git --git-common-dir` grouping for exact (non-heuristic) clustering; auto-rollback of partially-created worktrees on launch failure. Each is additive and can ship after the board proves out.

View File

@@ -47,7 +47,12 @@ export function normalizeDiffResult(raw: unknown): DiffResult | null {
.map(normalizeFile)
.filter((f): f is DiffFile => f !== null)
return { files, staged: o['staged'], truncated: o['truncated'] }
return {
files,
staged: o['staged'],
truncated: o['truncated'],
base: typeof o['base'] === 'string' ? o['base'] : undefined,
}
}
function normalizeFile(raw: unknown): DiffFile | null {
@@ -103,13 +108,26 @@ function normalizeLine(raw: unknown): DiffLine | null {
/* ── fetchDiff ───────────────────────────────────────────────────────────────── */
/** Options for {@link fetchDiff}: a `base` revision (branch/tag/sha) takes
* precedence over `staged` — the server ignores `staged` when `base` is set. */
export interface FetchDiffOpts {
staged?: boolean
base?: string
}
/**
* Fetch a diff from the server for the given repo path.
* Returns null on any error or invalid response (best-effort).
* Fetch a diff from the server for the given repo path. When `opts.base` is set
* the URL carries `&base=<rev>` (working-tree/staged are omitted); otherwise it
* carries `&staged=<bool>`. Returns null on any error or invalid response.
*/
export async function fetchDiff(repoPath: string, staged: boolean): Promise<DiffResult | null> {
export async function fetchDiff(repoPath: string, opts: FetchDiffOpts = {}): Promise<DiffResult | null> {
try {
const url = `/projects/diff?path=${encodeURIComponent(repoPath)}&staged=${staged}`
let url = `/projects/diff?path=${encodeURIComponent(repoPath)}`
if (opts.base !== undefined && opts.base !== '') {
url += `&base=${encodeURIComponent(opts.base)}`
} else {
url += `&staged=${opts.staged === true}`
}
const res = await fetch(url)
if (!res.ok) return null
const data: unknown = await res.json()
@@ -119,6 +137,70 @@ export async function fetchDiff(repoPath: string, staged: boolean): Promise<Diff
}
}
/* ── W4 git write: stage / commit / push helpers ─────────────────────────────── */
/** Server response shape for the three W4 git-write routes (mirrors GitOpResult).
* Only `ok` is guaranteed; the rest are route-specific success/failure fields. */
export interface GitOpResponse {
ok: boolean
status?: number
error?: string
staged?: boolean
count?: number
commit?: string
branch?: string
remote?: string
}
/** Narrow an untrusted JSON body to a GitOpResponse (mirrors isWorktreeResult). */
export function isGitOpResult(v: unknown): v is GitOpResponse {
return v !== null && typeof v === 'object' && typeof (v as Record<string, unknown>)['ok'] === 'boolean'
}
/** POST JSON to a same-origin git-write route; returns the parsed GitOpResponse
* or null on any transport/parse error. Same-origin, so the browser attaches the
* Origin header the server's CSRF guard checks — no manual header needed. */
async function postGitOp(url: string, body: unknown): Promise<GitOpResponse | null> {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
let data: unknown
try {
data = await res.json()
} catch {
data = null
}
if (isGitOpResult(data)) return data
// Non-JSON / unexpected body (e.g. a 403/429 with no body): synthesize a
// failure carrying the HTTP status so callers can show a message.
return { ok: false, status: res.status }
} catch {
return null
}
}
/** Stage (`stage=true`) or unstage the given repo-relative files. */
export function postStage(
repoPath: string,
files: string[],
stage: boolean,
): Promise<GitOpResponse | null> {
return postGitOp('/projects/git/stage', { path: repoPath, files, stage })
}
/** Commit the staged changes with `message`. */
export function postCommit(repoPath: string, message: string): Promise<GitOpResponse | null> {
return postGitOp('/projects/git/commit', { path: repoPath, message })
}
/** Push the current branch to its upstream (or `-u <sole-remote> <branch>`). */
export function postPush(repoPath: string): Promise<GitOpResponse | null> {
return postGitOp('/projects/git/push', { path: repoPath })
}
/* ── renderDiffFile ──────────────────────────────────────────────────────────── */
/** CSS class prefix for diff line kinds. */
@@ -130,13 +212,21 @@ const LINE_KIND_CLASS: Record<DiffLine['kind'], string> = {
meta: 'df-meta',
}
/** Optional per-file stage/unstage control (W4). `staged` picks the button label
* (Unstage in the staged view, Stage otherwise); `onToggle` fires on click. */
export interface StageControl {
staged: boolean
onToggle: (file: DiffFile) => void
}
/**
* Render a single DiffFile into an HTMLElement.
* Render a single DiffFile into an HTMLElement. When `stageCtl` is provided (W4,
* working/staged views only) a Stage/Unstage button is added to the file header.
*
* Security: ALL text content is set via textContent — zero innerHTML.
* <script>, ANSI sequences, & etc. are rendered as literal characters.
*/
export function renderDiffFile(file: DiffFile): HTMLElement {
export function renderDiffFile(file: DiffFile, stageCtl?: StageControl): HTMLElement {
const section = el('div', 'df-file')
// ── file header ──────────────────────────────────────────────────────────
@@ -157,6 +247,13 @@ export function renderDiffFile(file: DiffFile): HTMLElement {
const statusEl = el('span', `df-status df-status-${file.status}`, file.status)
header.append(pathEl, statsEl, statusEl)
if (stageCtl !== undefined) {
const stageBtn = el('button', 'df-file-stage', stageCtl.staged ? 'Unstage' : 'Stage')
stageBtn.addEventListener('click', () => stageCtl.onToggle(file))
header.append(stageBtn)
}
section.append(header)
// ── binary indicator ─────────────────────────────────────────────────────
@@ -197,10 +294,15 @@ function renderLine(line: DiffLine): HTMLElement {
/**
* Render a full DiffResult: all files grouped, with empty state and truncated warning.
* When `onToggleStage` is provided (W4, working/staged views), each file row gets a
* Stage/Unstage button whose direction is derived from `result.staged`.
*
* Security: ALL content via textContent — zero innerHTML.
*/
export function renderDiff(result: DiffResult): HTMLElement {
export function renderDiff(
result: DiffResult,
onToggleStage?: (file: DiffFile) => void,
): HTMLElement {
const container = el('div', 'df-result')
// Truncated warning
@@ -216,8 +318,11 @@ export function renderDiff(result: DiffResult): HTMLElement {
return container
}
const stageCtl: StageControl | undefined =
onToggleStage !== undefined ? { staged: result.staged, onToggle: onToggleStage } : undefined
for (const file of result.files) {
container.append(renderDiffFile(file))
container.append(renderDiffFile(file, stageCtl))
}
return container
@@ -240,6 +345,9 @@ export interface DiffViewerHandle {
export interface MountDiffViewerOpts {
/** Called when the viewer's close button or close() is invoked. */
onClose?: () => void
/** Base revisions (branches) offered in the "compare base" picker. When
* empty/omitted no picker is rendered (backward-compatible). */
bases?: string[]
}
/**
@@ -257,6 +365,7 @@ export function mountDiffViewer(
): DiffViewerHandle {
let destroyed = false
let staged = false
let base: string | null = null // null → working-tree/staged mode; string → base-diff
// ── skeleton ──────────────────────────────────────────────────────────────
const root = el('div', 'df-viewer')
@@ -268,15 +377,153 @@ export function mountDiffViewer(
const stagedBtn = el('button', 'df-tab', 'Staged')
const closeBtn = el('button', 'df-close', '✕ Close')
toolbar.append(workingBtn, stagedBtn, closeBtn)
toolbar.append(workingBtn, stagedBtn)
// Optional "compare against base" picker: Working tree + one option per base.
const bases = opts.bases ?? []
let baseSelect: HTMLSelectElement | null = null
if (bases.length > 0) {
baseSelect = document.createElement('select')
baseSelect.className = 'df-base-select'
baseSelect.setAttribute('aria-label', 'Compare against base')
const wtOpt = el('option', undefined, 'Working tree')
wtOpt.value = ''
baseSelect.append(wtOpt)
for (const b of bases) {
const opt = el('option', undefined, b) // textContent — inert (SEC-H4)
opt.value = b
baseSelect.append(opt)
}
baseSelect.addEventListener('change', () => {
const v = baseSelect?.value ?? ''
base = v === '' ? null : v
updateTabState()
void loadDiff()
})
toolbar.append(baseSelect)
}
toolbar.append(closeBtn)
root.append(toolbar)
// In base mode the Working/Staged tabs are inert (a base diff can't be staged)
// and the commit/push bar is hidden (you can only commit the working index).
function updateTabState(): void {
const inBase = base !== null
workingBtn.disabled = inBase
stagedBtn.disabled = inBase
workingBtn.classList.toggle('df-tab-disabled', inBase)
stagedBtn.classList.toggle('df-tab-disabled', inBase)
commitBar.style.display = inBase ? 'none' : ''
}
// Content area
const content = el('div', 'df-content')
root.append(content)
// ── W4 commit / push bar (working/staged mode only) ─────────────────────────
// Message textarea + Commit + Push. Hidden in base-compare mode. All rendered
// status/error text goes through textContent (SEC-H4) — zero innerHTML.
const commitBar = el('div', 'df-commitbar')
const commitMsg = el('textarea', 'df-commit-msg')
commitMsg.placeholder = 'Commit message'
commitMsg.rows = 2
const commitBtn = el('button', 'df-commit-btn', 'Commit')
commitBtn.disabled = true // enabled once the message is non-empty
const pushBtn = el('button', 'df-push-btn', 'Push')
const opStatus = el('div', 'df-op-status')
opStatus.style.display = 'none'
commitBar.append(commitMsg, commitBtn, pushBtn, opStatus)
root.append(commitBar)
container.append(root)
let busy = false
/** Reflect an in-flight git-write op: disable buttons, mark the root busy. */
function setBusy(v: boolean): void {
busy = v
root.classList.toggle('df-op-busy', v)
pushBtn.disabled = v
commitBtn.disabled = v || commitMsg.value.trim() === ''
}
/** Show a status line (error or notice) via textContent only (SEC-H4). */
function showOp(msg: string, kind: 'error' | 'notice'): void {
opStatus.textContent = msg
opStatus.className = kind === 'error' ? 'df-op-status df-op-error' : 'df-op-status df-op-notice'
opStatus.style.display = ''
}
function clearOp(): void {
opStatus.textContent = ''
opStatus.style.display = 'none'
}
/** Stage (working view) or unstage (staged view) one file, then reload. */
async function toggleStage(file: DiffFile): Promise<void> {
if (busy || destroyed) return
const files =
file.status === 'renamed' && file.oldPath !== file.newPath
? [file.oldPath, file.newPath]
: [file.newPath]
setBusy(true)
clearOp()
const r = await postStage(repoPath, files, !staged) // working → add; staged → unstage
if (destroyed) return
setBusy(false)
if (r === null || !r.ok) {
showOp(r?.error ?? 'Stage failed.', 'error')
return
}
void loadDiff()
}
/** Commit the staged changes with the textarea message, then reload. */
async function doCommit(): Promise<void> {
if (busy || destroyed) return
const message = commitMsg.value
if (message.trim() === '') return
setBusy(true)
clearOp()
const r = await postCommit(repoPath, message)
if (destroyed) return
setBusy(false)
if (r === null || !r.ok) {
showOp(r?.error ?? 'Commit failed.', 'error')
return
}
commitMsg.value = ''
commitBtn.disabled = true
showOp(r.commit !== undefined && r.commit !== '' ? `Committed ${r.commit}` : 'Committed.', 'notice')
void loadDiff()
}
/** Push the current branch; surface a safe success/error notice (no reload). */
async function doPush(): Promise<void> {
if (busy || destroyed) return
setBusy(true)
clearOp()
const r = await postPush(repoPath)
if (destroyed) return
setBusy(false)
if (r === null || !r.ok) {
showOp(r?.error ?? 'Push failed.', 'error')
return
}
const to = [r.branch, r.remote].filter((x): x is string => typeof x === 'string' && x !== '').join(' → ')
showOp(to !== '' ? `Pushed ${to}` : 'Pushed.', 'notice')
}
commitMsg.addEventListener('input', () => {
if (!busy) commitBtn.disabled = commitMsg.value.trim() === ''
})
commitBtn.addEventListener('click', () => {
void doCommit()
})
pushBtn.addEventListener('click', () => {
void doPush()
})
// ── event handlers ────────────────────────────────────────────────────────
workingBtn.addEventListener('click', () => {
if (!staged) return
@@ -304,7 +551,7 @@ export function mountDiffViewer(
content.textContent = 'Loading…'
const result = await fetchDiff(repoPath, staged)
const result = await fetchDiff(repoPath, base !== null ? { base } : { staged })
if (destroyed) return
@@ -312,7 +559,9 @@ export function mountDiffViewer(
if (result === null) {
content.append(el('div', 'df-error', 'Failed to load diff.'))
} else {
content.append(renderDiff(result))
// Stage toggles only in working/staged mode (never for a base-compare diff).
const onToggle = base !== null ? undefined : (file: DiffFile) => void toggleStage(file)
content.append(renderDiff(result, onToggle))
}
}

156
public/digest.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* public/digest.ts (W3 quick-wins c) — "while you were away" reconnect banner.
*
* On (re)connect, fetch GET /digest?since=<last-seen> and, if anything happened
* while away (finished / waiting / stuck), show ONE compact dismissible banner.
* The last-seen watermark is stored per-device in localStorage and advanced to
* the digest's generatedAt after each render so it never re-nags for old news.
*
* Best-effort: any fetch/parse failure → no banner (never throws). All text is
* set via textContent (SEC-H5) — session titles are attacker-influenced.
*/
import type { DigestResult } from '../src/types.js'
const LAST_SEEN_KEY = 'web-terminal:digest-last-seen'
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── last-seen watermark (per-device) ────────────────────────────────────────── */
/** Read the stored last-seen epoch-ms, or 0 (everything is new). Never throws. */
export function getLastSeen(): number {
try {
const raw = localStorage.getItem(LAST_SEEN_KEY)
if (raw === null) return 0
const n = Number(raw)
return Number.isFinite(n) && n >= 0 ? n : 0
} catch {
return 0
}
}
/** Persist the last-seen epoch-ms watermark. Best-effort. */
export function setLastSeen(ms: number): void {
try {
localStorage.setItem(LAST_SEEN_KEY, String(Math.floor(ms)))
} catch {
// storage unavailable (private mode) — the banner just re-shows next time
}
}
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
function num(o: Record<string, unknown>, key: string): number {
const v = o[key]
return typeof v === 'number' && Number.isFinite(v) ? v : 0
}
/** Coerce an untrusted GET /digest response into a DigestResult, or null. */
export function normalizeDigest(raw: unknown): DigestResult | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['generatedAt'] !== 'number' || !Number.isFinite(o['generatedAt'])) return null
return {
since: num(o, 'since'),
generatedAt: o['generatedAt'],
total: num(o, 'total'),
finished: num(o, 'finished'),
needsInput: num(o, 'needsInput'),
stuck: num(o, 'stuck'),
working: num(o, 'working'),
totalCostUsd: num(o, 'totalCostUsd'),
sessions: Array.isArray(o['sessions']) ? (o['sessions'] as DigestResult['sessions']) : [],
}
}
/* ── fetch ───────────────────────────────────────────────────────────────────── */
/** Fetch the digest since `since`. null on any error (best-effort). */
export async function fetchDigest(since: number): Promise<DigestResult | null> {
try {
if (typeof fetch === 'undefined') return null
const res = await fetch(`/digest?since=${encodeURIComponent(String(since))}`)
if (!res.ok) return null
return normalizeDigest(await res.json())
} catch {
return null
}
}
/* ── render (pure) ───────────────────────────────────────────────────────────── */
/** Count of things worth surfacing (finished / waiting / stuck). */
export function digestHighlightCount(d: DigestResult): number {
return d.finished + d.needsInput + d.stuck
}
/** Compact human summary, e.g. "2 finished · 1 waiting · 1 stuck". */
export function digestSummary(d: DigestResult): string {
const parts: string[] = []
if (d.finished > 0) parts.push(`${d.finished} finished`)
if (d.needsInput > 0) parts.push(`${d.needsInput} waiting for input`)
if (d.stuck > 0) parts.push(`${d.stuck} stuck`)
return parts.join(' · ')
}
/**
* Build the banner element for a digest, or null when nothing is worth showing.
* `onDismiss` is wired to the × button. All text via textContent (SEC-H5).
*/
export function renderDigestBanner(d: DigestResult, onDismiss: () => void): HTMLElement | null {
if (digestHighlightCount(d) === 0) return null
const banner = el('div', 'wya-banner')
banner.setAttribute('role', 'status')
banner.append(el('span', 'wya-title', 'While you were away'))
banner.append(el('span', 'wya-summary', digestSummary(d)))
if (d.totalCostUsd > 0) {
banner.append(el('span', 'wya-cost', `$${d.totalCostUsd.toFixed(2)} total`))
}
const dismiss = el('button', 'wya-dismiss', '✕')
dismiss.title = 'Dismiss'
dismiss.setAttribute('aria-label', 'Dismiss')
dismiss.addEventListener('click', onDismiss)
banner.append(dismiss)
return banner
}
/* ── mount ───────────────────────────────────────────────────────────────────── */
/**
* Fetch the digest since the stored last-seen and, if anything happened, prepend
* a dismissible banner to `host`. Advances the last-seen watermark to the
* digest's generatedAt so it doesn't re-nag on the next reconnect. Best-effort:
* a fetch failure shows no banner. Returns the banner element (or null).
*/
export async function mountDigest(host: HTMLElement): Promise<HTMLElement | null> {
const since = getLastSeen()
const d = await fetchDigest(since)
if (d === null) return null // best-effort — no banner on failure
// Advance the watermark now so a refresh (without a dismiss) doesn't re-nag.
setLastSeen(d.generatedAt)
const banner = renderDigestBanner(d, () => {
setLastSeen(d.generatedAt)
banner?.remove()
})
if (banner === null) return null
host.prepend(banner)
return banner
}

74
public/fanout.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* public/fanout.ts (W5 fan-out board) — PURE launch-command + branch-slug helpers.
*
* No DOM, no fetch, no side effects — every export is unit-testable in isolation.
* The load-bearing invariant is the prompt shell-quoting (shellSingleQuote): the
* prompt is typed as raw bytes into a lane's shell BEFORE `claude` parses it, so
* it must be wrapped as a single literal argv element — no `$(...)`, backtick,
* `;`, `&&`, or redirection may execute. NEVER String-concat the prompt into the
* command unquoted (see the plan's Security section).
*/
import type { PermissionMode } from '../src/types.js'
/** Max prompt length typed into a lane (bounded DoS + keeps the command sane). */
export const FANOUT_PROMPT_MAX = 4000
/** Default/max fan-out lanes (mirrors the server MAX_FANOUT_LANES default and the
* grid-6 board capacity). The FE stepper max and the launch clamp both use it. */
export const FANOUT_MAX_LANES = 6
/**
* POSIX single-quote a string so the shell passes it verbatim as ONE argv element.
* Wrap in single quotes and replace every embedded `'` with the classic
* `'\''` sequence (close-quote, escaped-quote, re-open-quote). Pure.
*/
export function shellSingleQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`
}
/**
* Normalize a prompt for typing into a PTY: collapse every line break (`\r`, `\n`,
* `\r\n`) to a single space — a bare newline keystroke would submit a partial line
* before `claude` starts — then trim and cap at FANOUT_PROMPT_MAX. Pure.
*/
export function sanitizePrompt(s: string): string {
return s.replace(/[\r\n]+/g, ' ').trim().slice(0, FANOUT_PROMPT_MAX)
}
/**
* Build the `claude` launch command for one lane. Single-quotes the sanitized
* prompt (injection-safe), prefixes `--permission-mode <m>` for non-default modes,
* and ends with `\r` so the shell auto-executes it. 'auto' is downgraded to
* 'default' when the server forbids it (SEC-M5 parity with resolveMode). Pure.
*/
export function buildFanoutCmd(
prompt: string,
mode: PermissionMode,
allowAutoMode: boolean,
): string {
const effective: PermissionMode = mode === 'auto' && !allowAutoMode ? 'default' : mode
const flag = effective === 'default' ? '' : `--permission-mode ${effective} `
return `claude ${flag}${shellSingleQuote(sanitizePrompt(prompt))}\r`
}
/** Lane branch name: `${base}-lane-${i}` (i is 1-based). Pure. */
export function laneBranch(base: string, i: number): string {
return `${base}-lane-${i}`
}
/**
* Derive a safe git-branch-base slug from a free-text prompt: lowercase, non-
* alphanumeric runs → single `-`, strip leading/trailing dashes, cap at 40 chars.
* Falls back to 'fanout' when nothing survives. The result is a valid branch base
* (passes validateBranchNameClient). Pure.
*/
export function slugify(prompt: string): string {
const slug = prompt
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
.replace(/-+$/g, '')
return slug || 'fanout'
}

262
public/gh-chip.ts Normal file
View File

@@ -0,0 +1,262 @@
/**
* public/gh-chip.ts (W3 PR + CI status chip) — render-only, mirrors public/diff.ts.
*
* Receives a pre-parsed PrStatus from the server (GET /projects/pr) and renders a
* compact chip: PR state · N checks passing · mergeable. Zero PR parsing lives
* here (parsing is in src/http/gh.ts). It NEVER throws and degrades to a
* self-explaining chip when gh is missing / unauthenticated / has no PR.
*
* Security: SEC-H4 — ALL text content is set via textContent / el(). Zero
* innerHTML anywhere in this file. A PR `title` is attacker-controllable (anyone
* who can open a PR on a repo the host can read); it appears as literal text.
*/
import type { PrAvailability, PrCheckSummary, PrStatus } from '../src/types.js'
/* ── constants ───────────────────────────────────────────────────────────────── */
const GH_INSTALL_URL = 'https://cli.github.com'
const VALID_AVAILABILITY: ReadonlySet<string> = new Set<PrAvailability>([
'ok',
'no-pr',
'not-installed',
'unauthenticated',
'disabled',
'error',
])
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
/** Create an element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── normalizePrStatus ───────────────────────────────────────────────────────── */
function normalizeChecks(raw: unknown): PrCheckSummary | undefined {
if (raw === null || typeof raw !== 'object') return undefined
const o = raw as Record<string, unknown>
if (
typeof o['total'] !== 'number' ||
typeof o['passing'] !== 'number' ||
typeof o['failing'] !== 'number' ||
typeof o['pending'] !== 'number'
) {
return undefined
}
return {
total: o['total'],
passing: o['passing'],
failing: o['failing'],
pending: o['pending'],
}
}
/**
* Coerce an untrusted API response into a PrStatus, or return null. Never throws.
* Mirrors normalizeDiffResult (diff.ts): a non-object or an unknown `availability`
* ⇒ null; otherwise the known optional fields are copied when well-typed.
*/
export function normalizePrStatus(raw: unknown): PrStatus | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['availability'] !== 'string' || !VALID_AVAILABILITY.has(o['availability'])) {
return null
}
const status: PrStatus = { availability: o['availability'] as PrAvailability }
if (typeof o['number'] === 'number') status.number = o['number']
if (typeof o['title'] === 'string') status.title = o['title']
if (typeof o['url'] === 'string') status.url = o['url']
if (o['state'] === 'open' || o['state'] === 'closed' || o['state'] === 'merged') {
status.state = o['state']
}
if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
if (
o['mergeable'] === 'mergeable' ||
o['mergeable'] === 'conflicting' ||
o['mergeable'] === 'unknown'
) {
status.mergeable = o['mergeable']
}
if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
const checks = normalizeChecks(o['checks'])
if (checks !== undefined) status.checks = checks
return status
}
/* ── fetchPrStatus ───────────────────────────────────────────────────────────── */
/**
* Fetch the PR + CI status for a repo path. Returns null on any error or invalid
* response (mirrors fetchDiff) — the caller degrades that to an 'error' chip.
*/
export async function fetchPrStatus(repoPath: string): Promise<PrStatus | null> {
try {
const res = await fetch(`/projects/pr?path=${encodeURIComponent(repoPath)}`)
if (!res.ok) return null
const data: unknown = await res.json()
return normalizePrStatus(data)
} catch {
return null
}
}
/* ── chipText (pure) ─────────────────────────────────────────────────────────── */
/** The rendered chip's label, modifier CSS classes, and tooltip title. `null`
* when the chip should be hidden (availability 'disabled'). */
export interface ChipText {
label: string
cls: string
title: string
}
/** PR-number prefix, reflecting draft / merged / closed state. */
function statePrefix(status: PrStatus): string {
const n = typeof status.number === 'number' ? `#${status.number}` : ''
if (status.isDraft === true) return `Draft ${n}`.trim()
if (status.state === 'merged') return `Merged ${n}`.trim()
if (status.state === 'closed') return `Closed ${n}`.trim()
return `PR ${n}`.trim()
}
/** State → modifier class (draft wins over the raw open/closed/merged state). */
function stateClass(status: PrStatus): string {
if (status.isDraft === true) return 'proj-pr-draft'
if (status.state === 'merged') return 'proj-pr-merged'
if (status.state === 'closed') return 'proj-pr-closed'
return 'proj-pr-open'
}
/** Checks segment (glyph + passing/total) and its class; null when total === 0. */
function checksSegment(checks: PrCheckSummary): { text: string; cls: string } | null {
if (checks.total <= 0) return null
if (checks.failing > 0) {
return { text: `${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-fail' }
}
if (checks.pending > 0) {
return { text: `${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-pending' }
}
return { text: `${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-ok' }
}
const DEGRADED: Record<Exclude<PrAvailability, 'ok' | 'disabled'>, ChipText> = {
'no-pr': { label: 'No PR', cls: 'proj-pr-none', title: 'No pull request for the current branch' },
'not-installed': {
label: 'gh not installed',
cls: 'proj-pr-unavailable',
title: `Install the GitHub CLI to see PR status: ${GH_INSTALL_URL}`,
},
unauthenticated: {
label: 'gh auth login',
cls: 'proj-pr-unavailable',
title: 'Run `gh auth login` on the host to see PR status',
},
error: {
label: 'PR status unavailable',
cls: 'proj-pr-unavailable',
title: 'Could not read PR status',
},
}
/**
* Pure map from a PrStatus to the chip's {label, cls, title}. Returns null for
* 'disabled' (the chip is hidden). For 'ok' the label combines the PR-state
* prefix, the checks glyph (✓ / ✕ / ⧗ passing/total), and a "⚠ conflicts" marker
* when mergeable is 'conflicting'; cls collects the matching modifier classes.
*/
export function chipText(status: PrStatus): ChipText | null {
if (status.availability === 'disabled') return null
if (status.availability !== 'ok') return DEGRADED[status.availability]
const parts: string[] = [statePrefix(status)]
const classes: string[] = [stateClass(status)]
if (status.checks !== undefined) {
const seg = checksSegment(status.checks)
if (seg !== null) {
parts.push(seg.text)
classes.push(seg.cls)
}
}
if (status.mergeable === 'conflicting') {
parts.push('⚠ conflicts')
classes.push('proj-pr-conflict')
}
return {
label: parts.join(' '),
cls: classes.join(' '),
title: status.title !== undefined && status.title !== '' ? status.title : parts[0] ?? 'PR',
}
}
/* ── renderPrChip ────────────────────────────────────────────────────────────── */
/**
* Render a PrStatus into a chip element. 'disabled' ⇒ an empty display:none span.
*
* Security: SEC-H4 — all text via textContent. The PR `title` (attacker-
* controllable) is set as the tooltip attribute AND appended as an inert text
* span; it can never inject an element.
*/
export function renderPrChip(status: PrStatus): HTMLElement {
const info = chipText(status)
if (info === null) {
const hidden = el('span', 'proj-pr-chip proj-pr-hidden')
hidden.style.display = 'none'
return hidden
}
const chip = el('span', `proj-pr-chip ${info.cls}`)
chip.title = info.title // attribute — never HTML-parsed (SEC-H4)
chip.append(el('span', 'proj-pr-label', info.label))
// The PR title is attacker-controllable; render it as literal text (SEC-H4).
if (status.availability === 'ok' && typeof status.title === 'string' && status.title !== '') {
chip.append(el('span', 'proj-pr-title', status.title))
}
return chip
}
/* ── mountPrChip ─────────────────────────────────────────────────────────────── */
/** Handle returned by mountPrChip for cleanup. */
export interface PrChipHandle {
destroy(): void
}
/**
* Mount a PR-status chip into `container`: show a loading placeholder, fetch the
* status, then swap in the resolved chip. A fetch failure degrades to an 'error'
* chip (never throws). destroy() removes the node and cancels the swap.
*/
export function mountPrChip(container: HTMLElement, repoPath: string): PrChipHandle {
let destroyed = false
container.textContent = ''
container.append(el('span', 'proj-pr-chip proj-pr-loading', '…'))
void (async () => {
const status = await fetchPrStatus(repoPath)
if (destroyed) return
container.textContent = ''
container.append(renderPrChip(status ?? { availability: 'error' }))
})()
return {
destroy() {
destroyed = true
container.textContent = ''
},
}
}

135
public/git-log.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* public/git-log.ts (W3 quick-wins d) — render-only recent-commit list.
*
* Fetches GET /projects/log for a repo and renders each commit as an inert row.
* Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws
* and degrades to a short inert message on any failure.
*
* Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A
* commit subject is attacker-influenced (anyone who can push to a repo the host
* can read), so it appears strictly as literal text.
*/
import type { CommitLogEntry, GitLogResult } from '../src/types.js'
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
/** Create an element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */
function normalizeCommit(raw: unknown): CommitLogEntry | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null
if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null
return { hash: o['hash'], at: o['at'], subject: o['subject'] }
}
/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */
export function normalizeGitLog(raw: unknown): GitLogResult | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (!Array.isArray(o['commits'])) return null
const commits = o['commits']
.map(normalizeCommit)
.filter((c): c is CommitLogEntry => c !== null)
return { commits, truncated: o['truncated'] === true }
}
/* ── fetch ───────────────────────────────────────────────────────────────────── */
/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */
export async function fetchGitLog(repoPath: string): Promise<GitLogResult | null> {
try {
if (typeof fetch === 'undefined') return null
const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`)
if (!res.ok) return null
return normalizeGitLog(await res.json())
} catch {
return null
}
}
/* ── render ──────────────────────────────────────────────────────────────────── */
/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */
function relTime(ms: number): string {
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60) return `${Math.floor(s)}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}d`
}
/** One commit row: short hash · relative time · subject (all inert text). */
function renderCommitRow(c: CommitLogEntry): HTMLElement {
const row = el('div', 'proj-commit-row')
row.append(el('span', 'proj-commit-hash', c.hash))
row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent
return row
}
/** Render a GitLogResult into a container (clears first). Empty → an inert note. */
export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
container.textContent = ''
if (log.commits.length === 0) {
container.append(el('div', 'proj-empty', 'No commits yet.'))
return
}
const list = el('div', 'proj-commitlog-list')
for (const c of log.commits) list.append(renderCommitRow(c))
container.append(list)
if (log.truncated) {
container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`))
}
}
/* ── mount ───────────────────────────────────────────────────────────────────── */
/** Handle returned by mountGitLog for cleanup. */
export interface GitLogHandle {
destroy(): void
}
/**
* Mount a recent-commit list into `container`: show a loading placeholder, fetch
* the log, then swap in the rows. A fetch failure degrades to a short inert
* message (never throws). destroy() removes the node and cancels the swap.
*/
export function mountGitLog(container: HTMLElement, repoPath: string): GitLogHandle {
let destroyed = false
container.textContent = ''
container.append(el('div', 'proj-commitlog-loading', 'Loading commits…'))
void (async () => {
const log = await fetchGitLog(repoPath)
if (destroyed) return
if (log === null) {
container.textContent = ''
container.append(el('div', 'proj-empty', 'Could not read recent commits.'))
return
}
renderGitLog(container, log)
})()
return {
destroy() {
destroyed = true
container.textContent = ''
},
}
}

91
public/link-paths.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* public/link-paths.ts — pure file-path matcher for the terminal link provider (W1).
*
* Finds `src/app.ts:42`, `README.md`, `../lib/util.rs:10:5` etc. in a line of
* terminal text so the terminal-session link provider can turn them into
* clickable "open in editor" links. Deliberately DOM-free / xterm-free so it is
* unit-testable in node and reusable by the diff/approval-preview views later.
*
* A candidate token is: an optional `./`, `../` or `dir/…/` prefix, a filename
* with a dot-extension, and an optional `:line` / `:line:col` suffix. A candidate
* is only linked when it is *probably* a real path — it has a `/` separator, OR a
* `:line` suffix, OR its extension is in the CODE_EXT allowlist. This links code
* paths while ignoring `example.com`, `v1.2.3`, `foo.bar`, and `12:34`.
*/
/** File extensions that mark a bare (slash-less, line-less) token as a code path. */
export const CODE_EXT: ReadonlySet<string> = new Set([
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
'py', 'go', 'rs', 'rb', 'java', 'kt',
'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'php', 'swift',
'css', 'scss', 'html', 'json', 'yaml', 'yml', 'toml',
'md', 'txt', 'sh', 'sql', 'vue', 'svelte',
])
export interface PathMatch {
/** Exact matched substring, incl. any `:line[:col]` suffix (e.g. "src/app.ts:42"). */
text: string
/** The file-path portion only, without the line/column suffix (e.g. "src/app.ts"). */
path: string
/** 1-based line number, when a `:line` suffix was present. */
line?: number
/** 1-based column, when a `:line:col` suffix was present. */
column?: number
/** 1-based column of the first char (maps to an xterm range.start.x). */
startX: number
/** 1-based column of the last char, inclusive (maps to an xterm range.end.x). */
endX: number
}
// A path candidate. The leading negative lookbehind rejects tokens glued to a
// preceding word char, '.', '/', ':', '@' or '-' — this keeps the matcher from
// grabbing the `path.html` tail of a `https://host/path.html` URL (which the
// WebLinksAddon owns) or the middle of a larger identifier. The optional prefix
// admits an absolute `/`, a `./` or a `../` before the dir segments.
// group 1 = the path (prefix + dirs + filename.ext), no line/col
// group 2 = line digits (optional)
// group 3 = column digits (optional)
const PATH_RE =
/(?<![\w./:@-])((?:\/|\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]*\.[A-Za-z0-9]+)(?::(\d+)(?::(\d+))?)?/g
/** Lower-cased extension of a path (after the last dot), or null when there is none. */
function extensionOf(pathPart: string): string | null {
const dot = pathPart.lastIndexOf('.')
if (dot < 0 || dot === pathPart.length - 1) return null
return pathPart.slice(dot + 1).toLowerCase()
}
/** A candidate is a real path iff it has a dir separator, a :line suffix, or a code extension. */
function isLikelyPath(pathPart: string, hasLine: boolean): boolean {
if (pathPart.includes('/')) return true
if (hasLine) return true
const ext = extensionOf(pathPart)
return ext !== null && CODE_EXT.has(ext)
}
/**
* Find every clickable path in a single line of terminal text. Ranges are
* 1-based columns to match xterm's IBufferRange (startX = index+1, endX inclusive).
*/
export function findPathMatches(lineText: string): PathMatch[] {
const matches: PathMatch[] = []
PATH_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = PATH_RE.exec(lineText)) !== null) {
const full = m[0]
const pathPart = m[1] as string
const hasLine = m[2] !== undefined
if (!isLikelyPath(pathPart, hasLine)) continue
const startIndex = m.index
matches.push({
text: full,
path: pathPart,
...(hasLine ? { line: Number(m[2]) } : {}),
...(m[3] !== undefined ? { column: Number(m[3]) } : {}),
startX: startIndex + 1,
endX: startIndex + full.length,
})
}
return matches
}

125
public/login.html Normal file
View File

@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Sign in — web-terminal</title>
<!--
w5-access-token login page. FULLY SELF-CONTAINED: inline <style> only, NO
<script> (the CSP is `script-src 'self'`, which blocks inline JS). Auth is a
native form POST → the server 302s and sets the HttpOnly cookie → the app
loads. Zero JS required. The error banner is revealed server-side by GET
/login?e=1 swapping the single `ERRSTATE` token on <body> for `show-error`.
-->
<style>
:root {
color-scheme: light dark;
--bg: #0b0e14;
--card: #131722;
--fg: #e6e9ef;
--muted: #8b93a7;
--accent: #4c8bf5;
--border: #262c3a;
--err-bg: #3a1720;
--err-fg: #ff9aa8;
--err-border: #6b2230;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6fb;
--card: #ffffff;
--fg: #1a1f2b;
--muted: #5a6274;
--accent: #2563eb;
--border: #dde2ec;
--err-bg: #fdecef;
--err-fg: #b42035;
--err-border: #f3c2ca;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: var(--bg);
color: var(--fg);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.card {
width: 100%;
max-width: 22rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.75rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
}
h1 { margin: 0 0 0.25rem; font-size: 1.15rem; }
p.sub { margin: 0 0 1.25rem; color: var(--muted); font-size: 0.85rem; }
label { display: block; margin-bottom: 0.4rem; font-size: 0.8rem; color: var(--muted); }
input[type="password"] {
width: 100%;
padding: 0.65rem 0.75rem;
font-size: 1rem;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 9px;
outline: none;
}
input[type="password"]:focus { border-color: var(--accent); }
button {
margin-top: 1rem;
width: 100%;
padding: 0.7rem;
font-size: 0.95rem;
font-weight: 600;
color: #fff;
background: var(--accent);
border: 0;
border-radius: 9px;
cursor: pointer;
}
button:hover { filter: brightness(1.06); }
.error-banner {
display: none;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
color: var(--err-fg);
background: var(--err-bg);
border: 1px solid var(--err-border);
border-radius: 9px;
}
body.show-error .error-banner { display: block; }
.note {
margin-top: 1.25rem;
font-size: 0.72rem;
line-height: 1.45;
color: var(--muted);
}
</style>
</head>
<body class="login ERRSTATE">
<main class="card">
<h1>Access token required</h1>
<p class="sub">This web-terminal is protected by a shared access token.</p>
<div class="error-banner" role="alert">Invalid token — please try again.</div>
<form method="POST" action="/auth">
<label for="token">Access token</label>
<input id="token" name="token" type="password" autocomplete="current-password" autofocus required />
<button type="submit">Unlock</button>
</form>
<p class="note">
Bar-raiser, not a substitute for TLS/Tailscale. On a plain <code>ws://</code> LAN
the token is sent in cleartext and can be replayed — only use this off-LAN over
an HTTPS/WSS relay or tunnel. Never port-forward the raw port to the internet.
</p>
</main>
</body>
</html>

View File

@@ -22,6 +22,7 @@ import { mountShortcuts } from './shortcuts.js'
import { mountShareSession } from './share.js'
import { mountGridToggle, matchFocusCycleKey } from './grid-layout.js'
import { mountGridPresets } from './grid-presets.js'
import { mountDigest } from './digest.js'
const paneHost = document.getElementById('term')
const tabs = document.getElementById('tabs')
@@ -118,6 +119,11 @@ mountGridPresets(toolbar, {
mountQrConnect(toolbar)
// W3(c): "while you were away" reconnect digest — one compact dismissible banner
// summarising what finished / needs input / got stuck since this device's last
// visit. Best-effort (no banner on fetch failure); advances its own last-seen.
void mountDigest(document.body)
// PWA: register the service worker (installable + offline shell, M4).
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {

View File

@@ -188,7 +188,9 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
*
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
* receives the class `tg-stale` so CSS can grey it out.
* receives the class `tg-stale` so CSS can grey it out. When `costBudgetUsd` is
* set (>0) and `costUsd >= costBudgetUsd`, the cost chip gets `tg-cost-warn`
* (W3 quick-wins b), mirroring the ctx>80% warn path.
*
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
@@ -198,6 +200,7 @@ export function renderTelemetryGauge(
container: HTMLElement,
telemetry: StatusTelemetry | null,
staleTtlMs: number,
costBudgetUsd?: number,
): void {
// Clear existing children
while (container.firstChild) container.removeChild(container.firstChild)
@@ -221,9 +224,13 @@ export function renderTelemetryGauge(
container.append(bar)
}
// Cost chip
// Cost chip — W3(b): warn-styled once cost crosses the configured budget.
if (telemetry.costUsd !== undefined) {
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
const cost = el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`)
if (costBudgetUsd !== undefined && costBudgetUsd > 0 && telemetry.costUsd >= costBudgetUsd) {
cost.classList.add('tg-cost-warn')
}
container.append(cost)
}
// Model chip

Binary file not shown.

70
public/queue.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* public/queue.ts — W2: client helper for the server-side PTY-inject queue.
*
* Enqueue a follow-up prompt that the server fires into a session's PTY the next
* time Claude goes idle (so "run tests → open a PR" advances itself while you're
* away). Enqueue is a same-origin HTTP POST (Origin-guarded server-side), NOT a
* WS frame — it must work with zero tabs open and target any session (e.g. from
* the manage page), not just the WS-bound one.
*
* Discipline (mirrors quick-reply): every call NEVER throws — a failed fetch or
* non-2xx response returns a falsy result the caller can ignore. Byte-shuttle:
* `text` is sent verbatim; `appendEnter` tells the server to append \r.
*/
/** Result of an enqueue attempt. `length` = the new queue depth on success. */
export interface EnqueueResult {
ok: boolean
/** New queue depth (present on success). */
length?: number
/** HTTP status on a non-2xx response (absent on network error). */
status?: number
}
/** POST a follow-up prompt to a session's inject queue. Never throws. */
export async function enqueueFollowup(
sessionId: string,
text: string,
appendEnter: boolean,
): Promise<EnqueueResult> {
try {
if (typeof fetch === 'undefined') return { ok: false }
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, appendEnter }),
})
if (!res.ok) return { ok: false, status: res.status }
const data = (await res.json()) as { length?: unknown }
return { ok: true, length: typeof data.length === 'number' ? data.length : undefined }
} catch {
return { ok: false }
}
}
/** DELETE all pending entries for a session (cancel-all). Returns success. Never throws. */
export async function clearQueue(sessionId: string): Promise<boolean> {
try {
if (typeof fetch === 'undefined') return false
const res = await fetch(`/live-sessions/${encodeURIComponent(sessionId)}/queue`, {
method: 'DELETE',
credentials: 'same-origin',
})
return res.ok
} catch {
return false
}
}
/**
* Extract the queue depth from an incoming server frame. Returns the length for a
* `{type:'queue',length}` frame, else null (so callers can filter unrelated
* frames). Defensive: tolerates any unknown input shape.
*/
export function queueLengthFromFrame(msg: unknown): number | null {
if (msg === null || typeof msg !== 'object') return null
const o = msg as Record<string, unknown>
if (o['type'] !== 'queue') return null
return typeof o['length'] === 'number' ? o['length'] : null
}

View File

@@ -147,6 +147,10 @@ function clearChildren(parent: HTMLElement): void {
export interface QuickReplyOpts {
/** Called with the full byte string to inject into the terminal. */
onSend: (data: string) => void
/** W2: optional. When provided, the snippet editor gains a "Queue" button that
* enqueues the typed text as a follow-up prompt (fired on next idle) instead of
* sending it now. `appendEnter` mirrors the editor checkbox. */
onQueue?: (text: string, appendEnter: boolean) => void
}
/** Returned by mountQuickReply to allow cleanup. */
@@ -165,7 +169,7 @@ export interface QuickReplyHandle {
* Labels are set via textContent — never innerHTML (SEC-L3).
*/
export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): QuickReplyHandle {
const { onSend } = opts
const { onSend, onQueue } = opts
// Track disposers for all event listeners so dispose() is clean.
const disposers: Array<() => void> = []
@@ -233,6 +237,22 @@ export function mountQuickReply(container: HTMLElement, opts: QuickReplyOpts): Q
editor.appendChild(labelInput)
editor.appendChild(enterWrap)
editor.appendChild(saveBtn)
// W2: "Queue" defers the typed text as a follow-up prompt (fires on next
// idle) rather than saving it as a chip. Shown only when a handler is wired.
if (onQueue !== undefined) {
const queueBtn = el('button', 'qr-editor-queue', 'Queue')
queueBtn.title = 'Queue as a follow-up (runs when Claude next goes idle)'
function onQueueClick() {
const text = textInput.value.trim()
if (!text) return // empty text — no-op, editor stays open
onQueue?.(text, enterCheck.checked)
editor.remove()
}
queueBtn.addEventListener('click', onQueueClick)
editor.appendChild(queueBtn)
}
editor.appendChild(cancelBtn)
container.appendChild(editor)

View File

@@ -456,6 +456,20 @@ body {
color: var(--text);
background: var(--surface-3);
}
.cell-keep {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 2px 4px;
border-radius: 5px;
}
.cell-keep:hover {
color: var(--text);
background: var(--surface-3);
}
.cell-monitor-btn {
border: 0;
background: transparent;
@@ -1086,6 +1100,7 @@ body {
inset: auto 0 calc(var(--keybar-h) + var(--safe-b)) 0;
z-index: 1050;
display: flex;
flex-wrap: wrap; /* W1: a preview row wraps below the label + buttons */
align-items: center;
gap: 10px;
padding: 10px 14px;
@@ -1097,6 +1112,31 @@ body {
.approval-label {
flex: 1 1 auto;
}
/* W1: approval preview (command/diff) — its own full-width row, scrollable. */
.approval-preview {
flex: 1 1 100%;
order: 3; /* below the label + buttons regardless of insertion order */
max-height: 30vh;
overflow: auto;
overflow-x: auto;
border: 1px solid rgba(245, 177, 76, 0.35);
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
padding: 8px 10px;
}
.approval-cmd {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: Menlo, Consolas, monospace;
font-size: 13px;
color: #f4f5f7;
}
.approval-truncated {
margin-top: 6px;
font-size: 12px;
opacity: 0.7;
}
#approvalbar button {
flex: none;
border: none;
@@ -1508,6 +1548,171 @@ body {
flex: none;
}
/* W3(a): ahead/behind sync chip (mirrors the .proj-branch chip look). */
.proj-sync {
font-size: 11px;
color: var(--amber);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 7px;
white-space: nowrap;
flex: none;
}
/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */
.tg-cost-warn {
color: var(--red);
font-weight: 600;
}
/* W3(d): recent-commit list in the project detail. */
.proj-commitlog {
margin: 4px 0 10px;
}
.proj-commitlog-loading {
font-size: 12px;
color: var(--text-faint);
}
.proj-commitlog-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.proj-commit-row {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
min-width: 0;
}
.proj-commit-hash {
font-family: Menlo, Consolas, monospace;
color: var(--accent);
flex: none;
}
.proj-commit-time {
color: var(--text-faint);
flex: none;
white-space: nowrap;
}
.proj-commit-subject {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.proj-commit-more {
font-size: 11px;
color: var(--text-faint);
margin-top: 4px;
}
/* W3(c): "while you were away" reconnect banner (compact, dismissible top bar). */
.wya-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: var(--accent-soft);
border-bottom: 1px solid var(--accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.wya-title {
font-weight: 600;
color: var(--accent);
flex: none;
}
.wya-summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wya-cost {
color: var(--text-faint);
flex: none;
}
.wya-dismiss {
flex: none;
border: none;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 2px 6px;
}
.wya-dismiss:hover {
color: var(--text);
}
/* W3: PR + CI status chip (mirrors the .proj-branch chip look). */
.proj-pr-host {
display: inline-flex;
flex: none;
}
.proj-pr-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
border-radius: 5px;
padding: 2px 7px;
white-space: nowrap;
max-width: 260px;
color: var(--text-faint);
background: var(--accent-soft);
}
.proj-pr-chip .proj-pr-title {
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-faint);
}
.proj-pr-loading {
opacity: 0.6;
}
/* PR-state modifiers */
.proj-pr-open .proj-pr-label {
color: var(--green);
}
.proj-pr-draft .proj-pr-label {
color: var(--text-faint);
}
.proj-pr-merged .proj-pr-label {
color: var(--accent);
}
.proj-pr-closed .proj-pr-label {
color: var(--red);
}
.proj-pr-none,
.proj-pr-unavailable {
color: var(--text-faint);
background: transparent;
}
/* Checks-rollup modifiers (colour the whole chip label) */
.proj-pr-checks-ok .proj-pr-label {
color: var(--green);
}
.proj-pr-checks-fail .proj-pr-label {
color: var(--red);
}
.proj-pr-checks-pending .proj-pr-label {
color: var(--amber);
}
/* Mergeable = conflicting */
.proj-pr-conflict {
box-shadow: inset 0 0 0 1px var(--red);
}
/* Meta line (last-active time) */
.proj-meta {
font-size: 11px;
@@ -1753,6 +1958,60 @@ body {
text-transform: uppercase;
color: var(--text-faint);
}
/* W5 fan-out form — sibling of the New Worktree form. */
.proj-fanout-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.proj-fanout-prompt {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 44px;
font: inherit;
}
.proj-fanout-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.proj-fanout-lanes {
width: 72px;
}
.proj-fanout-branch {
flex: 1 1 160px;
min-width: 120px;
}
.proj-fanout-error {
color: var(--danger, #e5534b);
font-size: 12px;
}
.proj-fanout-submit {
align-self: flex-start;
}
.proj-fanout-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* W5 fan-out status/error banner — fixed, dismissible, textContent only. */
.fanout-banner {
position: fixed;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
max-width: min(680px, 92vw);
z-index: 60;
padding: 10px 14px;
border-radius: 8px;
background: var(--surface-3, #2a2c33);
color: var(--text, #e7e8ec);
border: 1px solid var(--border, #3a3d46);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
font-size: 13px;
cursor: pointer;
}
/* Section header with an inline action (CLAUDE.md generate/update). */
.proj-section-row {
display: flex;
@@ -2194,3 +2453,87 @@ body.home-open #term {
.gp-save-btn:hover {
filter: brightness(1.05);
}
/* ── W4 git write: per-file Stage/Unstage toggle + commit/push bar ─────────── */
.df-file-header {
display: flex;
align-items: center;
gap: 8px;
}
.df-file-stage {
margin-left: auto;
flex: 0 0 auto;
padding: 3px 10px;
font-size: 12px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
}
.df-file-stage:hover {
background: var(--surface-3);
border-color: var(--accent);
}
.df-commitbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px;
border-top: 1px solid var(--border);
background: var(--surface-1);
}
.df-commit-msg {
flex: 1 1 220px;
min-width: 0;
resize: vertical;
padding: 8px;
font: inherit;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 6px;
}
.df-commit-msg:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.df-commit-btn,
.df-push-btn {
flex: 0 0 auto;
padding: 8px 16px;
font: inherit;
font-weight: 600;
border-radius: 6px;
border: 1px solid var(--accent);
background: var(--accent);
color: #1a1712;
cursor: pointer;
}
.df-push-btn {
background: var(--surface-2);
color: var(--text);
border-color: var(--border-strong);
}
.df-commit-btn:disabled,
.df-push-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.df-op-status {
flex: 1 1 100%;
font-size: 13px;
word-break: break-word;
}
.df-op-error {
color: var(--red);
}
.df-op-notice {
color: var(--text-dim);
}
.df-op-busy {
opacity: 0.7;
}

View File

@@ -16,6 +16,7 @@ const TITLES = {
'needs-input': 'Approval Needed',
done: 'Task Complete',
stuck: 'Task Stuck',
budget: 'Cost Budget Reached',
}
/**

View File

@@ -25,11 +25,26 @@ import { TerminalSession } from './terminal-session.js'
import { ICON_TIMELINE } from './icons.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.js'
import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
import {
mountProjects,
createWorktreeReq,
removeWorktreeReq,
validateBranchNameClient,
type ProjectsPanel,
} from './projects.js'
import { buildFanoutCmd, laneBranch, FANOUT_MAX_LANES } from './fanout.js'
import type {
ApprovalPreview,
ClaudeStatus,
FanoutLaunchOpts,
PermissionMode,
UiConfig,
} from '../src/types.js'
import { renderDiffFile } from './diff.js'
import { renderTelemetryGauge } from './preview-grid.js'
import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.js'
import { enqueueFollowup } from './queue.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
@@ -70,12 +85,35 @@ const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits
type HomeView = 'sessions' | 'projects'
/** W5: one lane of a fan-out group — its worktree + branch (the session that runs
* in it is tracked via the tagged TabEntry, matched by TabEntry.fanoutGroupId). */
interface FanoutLane {
branch: string
worktreePath: string
}
/** W5: an in-memory fan-out group — one prompt fanned across N lanes of one repo.
* Lives only for the session (v1): reload does not reconstruct the board (matches
* v0.5 "no auto-restore"); keepFanoutWinner operates on the currently-open lanes. */
interface FanoutGroup {
id: string
repoPath: string
repoName: string
prompt: string
lanes: FanoutLane[]
}
interface TabEntry {
session: TerminalSession
customTitle: string | null // user-set; null = use auto/fallback (persisted)
autoTitle: string | null // current folder from the terminal title
hasActivity: boolean // inactive tab got output since last viewed
el: HTMLDivElement | null // the .tab element (updated in place)
// W5: when set, this tab is a lane of a fan-out group (id) with its own worktree
// + branch; the cell gets a 🏆 Keep button that resolves the race for this lane.
fanoutGroupId?: string
worktreePath?: string
branch?: string
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
// optional inline-approve footer). One per tab; the grid lays these out.
cell: HTMLDivElement | null
@@ -139,6 +177,15 @@ export class TabApp {
// B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
// high-risk 'auto' permission mode is hidden/refused (SEC-M5).
private allowAutoMode = false
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
// gauge warn-styles the cost chip once costUsd >= this budget.
private costBudgetUsd = 0
// W5 fan-out board: the server MAX_FANOUT_LANES (from /config/ui) — the hard cap
// launchFanout clamps to (alongside grid-6 capacity + the server DoS cap).
private maxFanoutLanes = FANOUT_MAX_LANES
// W5: in-memory fan-out group model (session-scoped; not persisted in v1).
private readonly fanoutGroups = new Map<string, FanoutGroup>()
private fanoutBanner: HTMLElement | null = null
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
private timelineOpen = false
@@ -175,6 +222,7 @@ export class TabApp {
this.projects = mountProjects(this.paneHost, {
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
onEnterSession: (id) => this.openSession(id),
onFanout: (repoPath, repoName, opts) => void this.launchFanout(repoPath, repoName, opts),
})
this.segControl = this.buildSegControl()
@@ -190,6 +238,7 @@ export class TabApp {
this.setupQuickReply() // A3 chips above the key bar
this.setupTimelinePanel() // A4 activity timeline
this.setupVoiceOverlay() // A2 interim-transcript overlay
this.setupFanoutBanner() // W5 fan-out status/error banner
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
@@ -215,7 +264,21 @@ export class TabApp {
const keybar = document.getElementById('keybar')
if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar)
else document.body.appendChild(host)
mountQuickReply(host, { onSend: (data) => this.sendToActive(data) })
mountQuickReply(host, {
onSend: (data) => this.sendToActive(data),
// W2: "Queue" in the snippet editor enqueues a follow-up for the active
// session instead of sending it now (fires when Claude next goes idle).
onQueue: (text, appendEnter) => this.enqueueToActive(text, appendEnter),
})
}
/** W2: enqueue a follow-up prompt for the active session (best-effort — the
* POST is Origin-guarded server-side; failures are surfaced only via the badge
* not updating). No-op when no tab has attached yet. */
enqueueToActive(text: string, appendEnter: boolean): void {
const id = this.activeSessionId()
if (id === null) return
void enqueueFollowup(id, text, appendEnter)
}
/** A4: hidden activity-timeline panel; toggled per active session. */
@@ -245,6 +308,35 @@ export class TabApp {
this.voiceConfirmOverlay = confirmOverlay
}
/** W5: a dismissible banner for fan-out status/errors (partial-launch, keep-
* winner failures). textContent only (SEC-L3/H6); tap or auto-hide to clear. */
private setupFanoutBanner(): void {
const banner = document.createElement('div')
banner.id = 'fanout-banner'
banner.className = 'fanout-banner'
banner.style.display = 'none'
banner.addEventListener('click', () => {
banner.style.display = 'none'
})
document.body.appendChild(banner)
this.fanoutBanner = banner
}
private fanoutBannerTimer: ReturnType<typeof setTimeout> | null = null
/** Show a fan-out banner message (textContent only). Auto-hides after 8s. */
private showFanoutBanner(msg: string): void {
const banner = this.fanoutBanner
if (!banner) return
banner.textContent = msg // SEC-L3/H6: textContent, never innerHTML
banner.style.display = 'block'
if (this.fanoutBannerTimer !== null) clearTimeout(this.fanoutBannerTimer)
this.fanoutBannerTimer = setTimeout(() => {
banner.style.display = 'none'
this.fanoutBannerTimer = null
}, 8000)
}
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
private async loadUiConfig(): Promise<void> {
try {
@@ -259,6 +351,16 @@ export class TabApp {
) {
this.allowAutoMode = (data as UiConfig).allowAutoMode
}
// W3(b): read the cost budget (optional, present only when > 0 server-side).
const budget = (data as Record<string, unknown>)?.['costBudgetUsd']
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
this.costBudgetUsd = budget
}
// W5: read the fan-out lane cap (optional; older servers omit → keep default).
const lanes = (data as Record<string, unknown>)?.['maxFanoutLanes']
if (typeof lanes === 'number' && Number.isFinite(lanes) && lanes >= 2) {
this.maxFanoutLanes = Math.floor(lanes)
}
} catch {
// best-effort — leave allowAutoMode false (auto hidden) on any failure
}
@@ -366,16 +468,45 @@ export class TabApp {
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
// W1: show WHAT will run (command / diff) between the label and the buttons,
// so a one-tap remote approval is no longer blind. Absent for plan gates and
// unknown tools (no reviewable command/diff) → today's name-only bar.
const preview = session.pendingPreview
const previewNode = preview ? this.renderApprovalPreview(preview) : null
const middle = previewNode ? [previewNode] : []
if (session.pendingGate === 'plan') {
label.textContent = 'Claude finished planning — how should it proceed?'
this.approvalBar.append(label, ...this.planGateButtons(session))
this.approvalBar.append(label, ...middle, ...this.planGateButtons(session))
} else {
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
this.approvalBar.append(label, ...this.toolGateButtons(session))
this.approvalBar.append(label, ...middle, ...this.toolGateButtons(session))
}
this.approvalBar.style.display = 'flex'
}
/** W1: build the command/diff preview node for the approval bar. Untrusted,
* server-sanitized content is rendered via textContent / renderDiffFile ONLY
* (never innerHTML) — <script>, ANSI, & etc. appear as literal characters. */
private renderApprovalPreview(p: ApprovalPreview): HTMLElement {
const container = document.createElement('div')
container.className = 'approval-preview'
if (p.kind === 'command') {
const pre = document.createElement('pre')
pre.className = 'approval-cmd'
pre.textContent = p.text // textContent — attacker-influenced command bytes
container.append(pre)
} else {
container.append(renderDiffFile(p.file)) // diff.ts is innerHTML-free (SEC-H4)
}
if (p.truncated) {
const note = document.createElement('div')
note.className = 'approval-truncated'
note.textContent = '… truncated'
container.append(note)
}
return container
}
/** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
return [
@@ -695,6 +826,8 @@ export class TabApp {
},
// B2: telemetry is the single source of truth on the session; just re-render.
onTelemetry: () => this.refreshTab(entry),
// W2: pending inject-queue depth changed — re-render the "N queued" badge.
onQueue: () => this.refreshTab(entry),
// Split-grid: clicking anywhere in this pane makes it the focused quadrant.
onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
})
@@ -743,7 +876,23 @@ export class TabApp {
e.stopPropagation()
this.toggleMonitor(this.tabs.indexOf(entry))
})
head.append(monBtn, maxBtn)
// W5: 🏆 keep-winner — only shown (via renderCell) when this tab is a fan-out
// lane. Resolves the race: keep this lane, discard the others' worktrees.
const keepBtn = document.createElement('button')
keepBtn.type = 'button'
keepBtn.className = 'cell-keep'
keepBtn.textContent = '🏆'
keepBtn.title = 'Keep this lane, discard the others'
keepBtn.setAttribute('aria-label', 'Keep this fan-out lane as the winner')
keepBtn.style.display = 'none'
keepBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
keepBtn.addEventListener('click', (e) => {
e.stopPropagation()
if (entry.fanoutGroupId !== undefined) {
void this.keepFanoutWinner(entry.fanoutGroupId, entry.session.id ?? '')
}
})
head.append(monBtn, keepBtn, maxBtn)
// v2: drag a tab from the tab bar onto this quadrant to assign it here.
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
cell.append(head, session.el)
@@ -797,6 +946,134 @@ export class TabApp {
).length
}
/* ── W5 fan-out board ────────────────────────────────────────────────── */
/**
* Fan ONE prompt across N lanes of `repoPath`: create N worktrees SEQUENTIALLY
* (git's `worktree add` takes a repo lock — parallel adds race), then open one
* Claude session per created lane pre-injected with the same shell-quoted prompt,
* and lay them out on the split-grid board. Composition of shipped parts —
* createWorktreeReq + addEntry (openProject shape) + setGridLayout. Partial
* success is surfaced in the banner (no auto-rollback in v1); never throws.
*/
async launchFanout(repoPath: string, repoName: string, opts: FanoutLaunchOpts): Promise<void> {
const base = opts.branchBase.trim()
const branchErr = validateBranchNameClient(base)
if (branchErr !== null) {
this.showFanoutBanner(`Cannot fan out — ${branchErr}.`)
return
}
// Effective N: min(requested, server cap, grid-6 capacity). The remaining
// maxSessions liveCount bound is enforced server-side (assertUnderSessionCap
// throws → that lane shows exit(-1); reported as "started K of N" below).
const n = Math.min(
Math.max(2, Math.floor(opts.lanes)),
this.maxFanoutLanes,
FANOUT_MAX_LANES,
)
// Build the launch command ONCE — every lane runs the identical prompt.
const cmd = buildFanoutCmd(opts.prompt, this.resolveMode(opts.mode ?? 'default'), this.allowAutoMode)
const groupId = `fanout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const lanes: FanoutLane[] = []
const failures: string[] = []
for (let i = 1; i <= n; i++) {
const branch = laneBranch(base, i)
const r = await createWorktreeReq(repoPath, branch) // sequential (repo lock)
if (r.ok && typeof r.path === 'string') {
lanes.push({ branch, worktreePath: r.path })
} else {
failures.push(`${branch}: ${r.error ?? 'failed'}`)
}
}
if (lanes.length === 0) {
this.showFanoutBanner(`Fan-out failed — no lanes created. ${failures.join('; ')}`)
return
}
const group: FanoutGroup = { id: groupId, repoPath, repoName, prompt: opts.prompt, lanes }
this.fanoutGroups.set(groupId, group)
const created: TabEntry[] = []
for (const lane of lanes) {
const entry = this.addEntry(null, `${repoName}·${lane.branch}`, lane.worktreePath || undefined, cmd)
entry.fanoutGroupId = groupId
entry.worktreePath = lane.worktreePath
entry.branch = lane.branch
created.push(entry)
}
this.persist()
this.rebuild()
// Watch board: 2×2 up to 4 lanes, else 2×3 (grid-6 holds up to 6).
this.setGridLayout(lanes.length <= 4 ? 'grid-4' : 'grid-6')
const first = created[0]
if (first) this.activate(this.tabs.indexOf(first))
if (failures.length > 0) {
this.showFanoutBanner(`Started ${lanes.length} of ${n} lanes. Failed: ${failures.join('; ')}`)
}
}
/**
* Keep the winning lane and discard the others: a single batch confirm, then
* each losing lane's tab is closed (detach — its PTY is reclaimed by IDLE_TTL)
* and its worktree removed (auto-forcing on a 409 dirty tree, inside the one
* confirm — no per-lane prompt). The winner's tab + worktree stay so the user
* runs the merge inside it (one-click merge is out of v1 scope). Never throws.
*/
async keepFanoutWinner(groupId: string, winnerSessionId: string): Promise<void> {
const group = this.fanoutGroups.get(groupId)
if (!group) return
const laneTabs = this.tabs.filter((t) => t.fanoutGroupId === groupId)
const winner = laneTabs.find((t) => t.session.id === winnerSessionId)
if (!winner) {
// No lane matches — refuse rather than treat every lane (incl. the intended
// winner) as a loser and delete them all. Happens if the winner hasn't
// attached yet (session.id still null).
this.showFanoutBanner('Cannot keep this lane yet — it has not connected.')
return
}
const losers = laneTabs.filter((t) => t !== winner)
const winnerBranch = winner.branch ?? winnerSessionId
if (
!window.confirm(
`Keep ${winnerBranch} and discard the other ${losers.length} lane(s) (delete their worktrees)?`,
)
) {
return
}
const failures: string[] = []
for (const loser of losers) {
const idx = this.tabs.indexOf(loser)
if (idx >= 0) this.closeTab(idx) // detach; PTY reaped by IDLE_TTL
const wt = loser.worktreePath
if (wt !== undefined && wt !== '') {
let r = await removeWorktreeReq(group.repoPath, wt, false)
if (!r.ok && r.status === 409) {
r = await removeWorktreeReq(group.repoPath, wt, true) // dirty → force (batch-confirmed)
}
if (!r.ok) failures.push(`${loser.branch ?? wt}: ${r.error ?? 'failed'}`)
}
}
this.fanoutGroups.delete(groupId)
winner.fanoutGroupId = undefined // drop the 🏆 button — the race is resolved
if (losers.length > 0) {
this.setGridLayout('single')
const wi = this.tabs.indexOf(winner)
if (wi >= 0) this.activate(wi)
}
this.persist()
this.rebuild()
if (failures.length > 0) {
this.showFanoutBanner(`Kept ${winnerBranch}. Some worktrees could not be removed: ${failures.join('; ')}`)
}
}
activate(i: number): void {
if (i < 0 || i >= this.tabs.length) return
// Board-aware focus: in a split grid the focused pane must be ON the board,
@@ -1289,6 +1566,9 @@ export class TabApp {
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
const monBtn = cell.querySelector<HTMLElement>('.cell-monitor-btn')
if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor)
// W5: the 🏆 keep button appears only on a fan-out lane's cell (any layout).
const keepBtn = cell.querySelector<HTMLElement>('.cell-keep')
if (keepBtn) keepBtn.style.display = entry.fanoutGroupId !== undefined ? '' : 'none'
const nameEl = cell.querySelector('.cell-name')
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
@@ -1387,8 +1667,14 @@ export class TabApp {
if (label) label.textContent = title
const claude = el.querySelector('.tab-claude')
if (claude) claude.textContent = claudeIcon(cs)
// W2: "⧗N" when the session has queued follow-ups, else empty (badge hidden).
const queue = el.querySelector('.tab-queue')
if (queue) {
const n = entry.session.queueLength
queue.textContent = n > 0 ? `${n}` : ''
}
const gauge = el.querySelector<HTMLElement>('.tab-gauge')
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS, this.costBudgetUsd) // B2 + W3(b)
}
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
@@ -1482,6 +1768,11 @@ export class TabApp {
claude.className = 'tab-claude'
tabEl.appendChild(claude)
// W2: pending inject-queue depth badge (filled in by refreshTab).
const queue = document.createElement('span')
queue.className = 'tab-queue'
tabEl.appendChild(queue)
// B2: per-tab telemetry gauge container (filled in by refreshTab).
const gauge = document.createElement('span')
gauge.className = 'tab-gauge'

View File

@@ -9,11 +9,12 @@
*/
import { Terminal } from '@xterm/xterm'
import type { ITheme } from '@xterm/xterm'
import type { ITheme, ILink, ILinkProvider, IDisposable } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import type {
ApprovalPreview,
ClaudeStatus,
ClientMessage,
PermissionGate,
@@ -22,6 +23,7 @@ import type {
StatusTelemetry,
} from '../src/types.js'
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
import { findPathMatches, type PathMatch } from './link-paths.js'
// Delay after the shell is ready before typing a session's initial command
// (e.g. `claude --resume …`), giving the shell time to finish its prompt.
@@ -47,6 +49,53 @@ function buildWsUrl(): string {
return `${scheme}://${location.host}/term`
}
/** W1: only these URL schemes may be opened from a clicked terminal link. */
const WEB_LINK_SCHEMES: ReadonlySet<string> = new Set(['http:', 'https:', 'mailto:'])
/**
* Open a URL clicked in the terminal (W1 hardening). Allowlists the scheme —
* blocking `javascript:` / `data:` / `file:` URIs — and opens with
* `noopener,noreferrer` to prevent reverse-tabnabbing. Exported for unit tests.
*/
export function openWebLink(uri: string): void {
let protocol: string
try {
protocol = new URL(uri).protocol
} catch {
return // not a parseable absolute URL → ignore
}
if (!WEB_LINK_SCHEMES.has(protocol)) return
window.open(uri, '_blank', 'noopener,noreferrer')
}
/** POSIX absolute path check (host is macOS/Linux — see out-of-scope notes). */
function isAbsolutePath(p: string): boolean {
return p.startsWith('/')
}
/**
* Resolve a path emitted in terminal output to an absolute path. Absolute paths
* pass through; relative paths are joined onto `cwd` (from OSC-7) and normalized
* (`.`/`..` collapsed). Returns null when the path is relative but no cwd is
* known yet — the caller then declines to open and shows a status line.
*/
function resolvePath(cwd: string | null, rawPath: string): string | null {
if (isAbsolutePath(rawPath)) return rawPath
if (cwd === null) return null
const base = cwd.endsWith('/') ? cwd.slice(0, -1) : cwd
const segments = base.split('/')
for (const seg of rawPath.split('/')) {
if (seg === '' || seg === '.') continue
if (seg === '..') {
if (segments.length > 1) segments.pop()
continue
}
segments.push(seg)
}
const joined = segments.join('/')
return joined.startsWith('/') ? joined : `/${joined}`
}
/** Connection state, surfaced as a colored status dot on the tab. */
export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'exited'
@@ -66,6 +115,9 @@ export interface TerminalSessionOpts {
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void
/** Optional: fired when the pending inject-queue depth changes (W2), so the tab
* can render an "N queued" badge. Broadcast to every mirrored device. */
onQueue?: (length: number) => void
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
onFocus?: () => void
@@ -88,6 +140,7 @@ export class TerminalSession {
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly onQueue: ((length: number) => void) | undefined
private readonly onFocus: (() => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
@@ -98,7 +151,11 @@ export class TerminalSession {
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private telemetryValue: StatusTelemetry | null = null
private queueLengthValue = 0
private pendingGateValue: PermissionGate | null = null
// W1: bounded command/diff preview of the held tool, from the last pending
// status frame. Null when no approval is held or the tool wasn't previewable.
private pendingPreviewValue: ApprovalPreview | null = null
// VC: nonce that increments on every false→true pendingApproval flip — lets a
// caller (e.g. a voice command captured at PTT-start) detect a stale gate: a
// slow transcript must not resolve a NEWER held permission than the one it
@@ -112,6 +169,8 @@ export class TerminalSession {
private resizeTimer: ReturnType<typeof setTimeout> | null = null
private initialInputTimer: ReturnType<typeof setTimeout> | null = null
private exitListener: { dispose(): void } | null = null
private linkProviderDisposable: IDisposable | null = null
private openPathInFlight = false
private isConnecting = false
private disposed = false
private lastCols = 0
@@ -125,6 +184,7 @@ export class TerminalSession {
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.onQueue = opts.onQueue
this.onFocus = opts.onFocus
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
@@ -145,8 +205,13 @@ export class TerminalSession {
this.term.loadAddon(this.fitAddon)
this.searchAddon = new SearchAddon()
this.term.loadAddon(this.searchAddon)
this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it
// M2/W1: tap a URL Claude prints to open it — hardened handler (scheme
// allowlist + noopener,noreferrer) instead of the addon's default activation.
this.term.loadAddon(new WebLinksAddon((_event, uri) => openWebLink(uri)))
this.term.open(this.el)
// W1: clickable file paths (`src/app.ts:42`) → jump to file:line in the host
// editor. Registered after open() so the linkifier service is available.
this.linkProviderDisposable = this.term.registerLinkProvider(this.makePathLinkProvider())
this.term.onData((data) => this.send(data))
// Tab title = current folder, derived from the shell's OSC title.
@@ -196,12 +261,24 @@ export class TerminalSession {
return this.telemetryValue
}
/** W2: current pending inject-queue depth (0 when empty), from the last
* `queue` frame. Every mirrored device sees the same value (broadcast). */
get queueLength(): number {
return this.queueLengthValue
}
/** The gate kind from the last pending status frame (B4): 'plan' or 'tool'.
* Null when no approval is held or the last status had no gate. */
get pendingGate(): PermissionGate | null {
return this.pendingGateValue
}
/** W1: bounded command/diff preview of the held tool (from the last pending
* status frame), or null when nothing is held / the tool wasn't previewable. */
get pendingPreview(): ApprovalPreview | null {
return this.pendingPreviewValue
}
/** Nonce counting false→true pendingApproval flips (VC stale-gate guard, §5). */
get pendingEpoch(): number {
return this.pendingEpochValue
@@ -317,6 +394,9 @@ export class TerminalSession {
this.pendingApprovalValue = nextPending
this.pendingToolValue = nextPending ? msg.detail : undefined
this.pendingGateValue = msg.gate ?? null
// W1: keep the preview only while an approval is held; a non-pending
// status (approve/reject resolved) clears it so the bar hides cleanly.
this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
@@ -325,6 +405,13 @@ export class TerminalSession {
this.onTelemetry?.(msg.telemetry)
break
}
case 'queue': {
// W2: pending inject-queue depth changed — update the badge on every
// mirrored device (shared session).
this.queueLengthValue = msg.length
this.onQueue?.(msg.length)
break
}
case 'exit': {
this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : ''
@@ -378,15 +465,80 @@ export class TerminalSession {
this.sendMsg({ type: 'input', data })
}
/**
* W1: a link provider that turns file paths in terminal output into clickable
* links. xterm passes a 1-based buffer row; getLine wants a 0-based index, and
* the range's y is the same 1-based row.
*/
private makePathLinkProvider(): ILinkProvider {
return {
provideLinks: (bufferLineNumber, callback) => {
const line = this.term.buffer.active.getLine(bufferLineNumber - 1)
if (!line) {
callback(undefined)
return
}
const matches = findPathMatches(line.translateToString(true))
if (matches.length === 0) {
callback(undefined)
return
}
const links: ILink[] = matches.map((match) => ({
text: match.text,
range: {
start: { x: match.startX, y: bufferLineNumber },
end: { x: match.endX, y: bufferLineNumber },
},
activate: () => this.openPath(match),
}))
callback(links)
},
}
}
/**
* Open a clicked file path in the host editor (W1). Relative paths are resolved
* against the OSC-7 cwd; if no cwd is known the click is declined with a status
* line. An in-flight guard stops rapid clicks from spawning many editor
* processes. Failures are shown as a non-blocking status line, never thrown.
*/
private openPath(match: PathMatch): void {
const abs = resolvePath(this.cwdValue, match.path)
if (abs === null) {
this.term.write(statusLine(`cannot open ${match.path}: working dir unknown`))
return
}
if (this.openPathInFlight) return
this.openPathInFlight = true
const body: { file: string; line?: number } =
match.line !== undefined ? { file: abs, line: match.line } : { file: abs }
fetch('/open-in-editor', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
.then((res) => {
if (!res.ok) this.term.write(statusLine(`cannot open ${match.path} (${res.status})`))
})
.catch(() => {
this.term.write(statusLine(`cannot open ${match.path}: request failed`))
})
.finally(() => {
this.openPathInFlight = false
})
}
/** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
* change (B4): mode is included only when explicitly provided so the server
* can distinguish "approve with default" from "approve, keep existing mode". */
approve(mode?: PermissionMode): void {
this.pendingApprovalValue = false
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
this.sendMsg({ type: 'approve', ...(mode !== undefined ? { mode } : {}) })
}
reject(): void {
this.pendingApprovalValue = false
this.pendingPreviewValue = null // W1: resolved → drop the stale preview
this.sendMsg({ type: 'reject' })
}
@@ -464,6 +616,10 @@ export class TerminalSession {
this.initialInputTimer = null
}
this.resizeObserver.disconnect()
if (this.linkProviderDisposable !== null) {
this.linkProviderDisposable.dispose()
this.linkProviderDisposable = null
}
if (this.ws !== null) {
try {
this.ws.close()

View File

@@ -59,10 +59,22 @@ const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_DIFF_MAX_FILES = 300
// W3 PR + CI status chip (gh) — larger timeout than diff: gh hits the network
const DEFAULT_GH_TIMEOUT_MS = 8_000
// B2 statusline telemetry
const DEFAULT_STATUSLINE_TTL_MS = 30_000
// B3 worktrees
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
// W5 fan-out board — max lanes one task may be fanned across (matches grid-6 capacity)
const DEFAULT_MAX_FANOUT_LANES = 6
// W4 git write (stage / commit / push)
const DEFAULT_GIT_OPS_TIMEOUT_MS = 10_000 // stage/commit exec bound (local, like worktree)
const DEFAULT_GIT_PUSH_TIMEOUT_MS = 120_000 // push is network-bound → longer bound
const DEFAULT_COMMIT_MSG_MAX_LEN = 5_000 // commit-message length cap
// W2 inject queue
const DEFAULT_QUEUE_MAX_ITEMS = 10
const DEFAULT_QUEUE_ITEM_MAX_BYTES = 4096
const DEFAULT_QUEUE_SETTLE_MS = 1500
/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */
const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
@@ -85,6 +97,22 @@ function parseNonNegativeInt(
return n
}
/** Parse a non-negative float env value (0 allowed), or the fallback when unset. */
function parseNonNegativeFloat(
raw: string | undefined,
label: string,
fallback: number,
): number {
if (raw === undefined) return fallback
const n = Number(raw)
if (!Number.isFinite(n) || n < 0) {
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative number`,
)
}
return n
}
/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */
function parseBool(raw: string | undefined, fallback: boolean): boolean {
const v = raw?.trim().toLowerCase()
@@ -141,6 +169,31 @@ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly s
return Object.freeze(roots.length > 0 ? roots : [homeDir])
}
/**
* WEBTERM_TOKEN charset+length policy (w5-access-token). URL/cookie-safe chars
* only (no `;`, space, `%`, or control chars) so the value cannot inject a
* Set-Cookie/header split or create query-string `%`-encoding ambiguity; the
* ≥16 floor multiplies brute-force cost against the /auth rate limiter.
*/
const WEBTERM_TOKEN_RE = /^[A-Za-z0-9._~+/=-]{16,512}$/
/**
* Parse WEBTERM_TOKEN. Unset/empty ⇒ undefined (auth DISABLED, LAN zero-config
* preserved). When present it must match WEBTERM_TOKEN_RE; invalid ⇒ throw
* (fail-fast, like parsePort). The token value is NEVER included in the error
* message (secret hygiene).
*/
function parseWebtermToken(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') return undefined
if (!WEBTERM_TOKEN_RE.test(raw)) {
throw new Error(
'Invalid config: WEBTERM_TOKEN must be 16512 characters from the ' +
'URL/cookie-safe set [A-Za-z0-9._~+/=-] (min length 16).',
)
}
return raw
}
function parsePort(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_PORT
const n = Number(raw)
@@ -301,6 +354,10 @@ export function loadConfig(env: EnvLike): Config {
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
// w5-access-token — optional shared access token (SECRET — never logged here).
// Unset/empty ⇒ undefined ⇒ auth disabled (LAN zero-config unchanged).
const webtermToken = parseWebtermToken(env['WEBTERM_TOKEN'])
// v0.6 Project Manager — discovery config
const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir)
const projectScanDepth = parseNonNegativeInt(
@@ -361,6 +418,10 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_DIFF_MAX_FILES,
)
// W3 PR + CI status chip (gh)
const ghEnabled = parseBool(env['GH_ENABLED'], true)
const ghTimeoutMs = parseNonNegativeInt(env['GH_TIMEOUT_MS'], 'GH_TIMEOUT_MS', DEFAULT_GH_TIMEOUT_MS)
// B2 statusLine telemetry
const statuslineTtlMs = parseNonNegativeInt(
env['STATUSLINE_TTL_MS'],
@@ -368,6 +429,9 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_STATUSLINE_TTL_MS,
)
// W3 quick-wins (b) cost budget guard — dollars, float ≥ 0; 0/unset = disabled.
const costBudgetUsd = parseNonNegativeFloat(env['COST_BUDGET_USD'], 'COST_BUDGET_USD', 0)
// B3 git worktree creation
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
@@ -377,6 +441,32 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_WORKTREE_TIMEOUT_MS,
)
// W5 fan-out board — cap on lanes one task may be fanned across (DoS guard,
// like MAX_SESSIONS). 0 is allowed (disables fan-out) via parseNonNegativeInt.
const maxFanoutLanes = parseNonNegativeInt(
env['MAX_FANOUT_LANES'],
'MAX_FANOUT_LANES',
DEFAULT_MAX_FANOUT_LANES,
)
// W4 git write (stage / commit / push) — the highest-risk write channel
const gitOpsEnabled = parseBool(env['GIT_OPS_ENABLED'], true) // master kill-switch
const gitOpsTimeoutMs = parseNonNegativeInt(
env['GIT_OPS_TIMEOUT_MS'],
'GIT_OPS_TIMEOUT_MS',
DEFAULT_GIT_OPS_TIMEOUT_MS,
)
const gitPushTimeoutMs = parseNonNegativeInt(
env['GIT_PUSH_TIMEOUT_MS'],
'GIT_PUSH_TIMEOUT_MS',
DEFAULT_GIT_PUSH_TIMEOUT_MS,
)
const commitMsgMaxLen = parseNonNegativeInt(
env['COMMIT_MSG_MAX_LEN'],
'COMMIT_MSG_MAX_LEN',
DEFAULT_COMMIT_MSG_MAX_LEN,
)
// B4 permission mode relay
const defaultPermissionMode = parsePermissionMode(
env['DEFAULT_PERMISSION_MODE'],
@@ -384,6 +474,16 @@ export function loadConfig(env: EnvLike): Config {
)
const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false) // default off (SEC-M5)
// W2 server-side PTY-inject follow-up queue
const queueEnabled = parseBool(env['QUEUE_ENABLED'], true)
const queueMaxItems = parseNonNegativeInt(env['QUEUE_MAX_ITEMS'], 'QUEUE_MAX_ITEMS', DEFAULT_QUEUE_MAX_ITEMS)
const queueItemMaxBytes = parseNonNegativeInt(
env['QUEUE_ITEM_MAX_BYTES'],
'QUEUE_ITEM_MAX_BYTES',
DEFAULT_QUEUE_ITEM_MAX_BYTES,
)
const queueSettleMs = parseNonNegativeInt(env['QUEUE_SETTLE_MS'], 'QUEUE_SETTLE_MS', DEFAULT_QUEUE_SETTLE_MS)
// ── assemble + freeze ───────────────────────────────────────────────────────
// `satisfies Config` on the base object verifies all existing Config fields
// are present without triggering excess-property checks on the v0.7 additions.
@@ -403,6 +503,7 @@ export function loadConfig(env: EnvLike): Config {
previewBytes,
useTmux,
allowedOrigins,
webtermToken,
projectRoots,
projectScanDepth,
projectScanTtlMs,
@@ -429,11 +530,26 @@ export function loadConfig(env: EnvLike): Config {
diffTimeoutMs,
diffMaxBytes,
diffMaxFiles,
ghEnabled,
ghTimeoutMs,
statuslineTtlMs,
costBudgetUsd,
worktreeEnabled,
worktreeRoot,
worktreeTimeoutMs,
// W5 fan-out board
maxFanoutLanes,
// W4 git write (stage / commit / push)
gitOpsEnabled,
gitOpsTimeoutMs,
gitPushTimeoutMs,
commitMsgMaxLen,
defaultPermissionMode,
allowAutoMode,
// W2 inject queue
queueEnabled,
queueMaxItems,
queueItemMaxBytes,
queueSettleMs,
})
}

View File

@@ -0,0 +1,263 @@
/**
* src/http/approval-preview.ts (W1) — derive a bounded, sanitized preview of a
* held tool approval so a remote one-tap approve is no longer blind.
*
* Pure + never throws (SEC-M7 discipline, mirroring parseHookEvent). The input
* is the Claude Code hook `tool_input` — attacker-influenced content arriving on
* the loopback `/hook/permission` route. It is treated as `unknown`, every field
* narrowed before use, and every emitted string passed through sanitizeField
* (strips ASCII control chars incl. ANSI ESC / BEL) and line/byte-capped so a
* hostile or huge tool_input can never bloat the broadcast / pendingApprovals.
*
* Mapping:
* Bash → { kind:'command', text }
* Edit / Write / MultiEdit / NotebookEdit → { kind:'diff', file }
* anything else / malformed input → null (FE falls back to a name-only bar)
*
* SECURITY: this module NEVER runs a command, touches fs, or interprets a path —
* it only produces display data. Rendering is textContent / renderDiffFile only
* (public/*), never innerHTML.
*/
import type { ApprovalPreview, DiffFile, DiffHunk, DiffLine } from '../types.js';
import { sanitizeField } from '../session/timeline.js';
// ── bounds (security limits, not user knobs — see plan §"New env vars") ────────
/** Max diff/command lines emitted across the whole preview. */
export const PREVIEW_MAX_LINES = 40;
/** Per-line char cap (matches sanitizeField's default). */
export const PREVIEW_MAX_LINE_LEN = 200;
/** Hard total-byte cap on the emitted preview text (all lines combined). */
export const PREVIEW_MAX_BYTES = 4096;
/** Tools that map to a diff preview (mirrors timeline.ts EDIT_TOOLS semantics). */
const EDIT_TOOLS: ReadonlySet<string> = new Set([
'Edit',
'Write',
'MultiEdit',
'NotebookEdit',
]);
// ── helpers ────────────────────────────────────────────────────────────────────
/** Narrow an unknown value to a plain object record, or null. */
function asRecord(v: unknown): Record<string, unknown> | null {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
return v as Record<string, unknown>;
}
/** Sanitize a single line: strip control/ANSI chars (via sanitizeField), cap to
* PREVIEW_MAX_LINE_LEN, and report whether the cap actually clipped content (so
* a hidden over-long line still flags the preview as truncated). Newlines never
* reach here — callers split on '\n' first so structure survives. */
function sanitizeLine(s: string): { text: string; clipped: boolean } {
// sanitizeField with an effectively-unbounded max = control-strip only (DRY:
// reuse the SEC-H6 regex), then apply OUR length cap + clip detection here.
const stripped = sanitizeField(s, Number.MAX_SAFE_INTEGER);
const text = stripped.slice(0, PREVIEW_MAX_LINE_LEN);
return { text, clipped: stripped.length > PREVIEW_MAX_LINE_LEN };
}
/** Split a raw multi-line blob into individual (still-raw) lines. */
function splitLines(s: string): string[] {
return s.split('\n');
}
/** UTF-8 byte length of a string (whole-string). */
function byteLen(s: string): number {
return Buffer.byteLength(s, 'utf8');
}
/** A budget shared across all emitted lines of one preview. */
interface Budget {
linesLeft: number;
bytesLeft: number;
truncated: boolean;
}
function newBudget(): Budget {
return { linesLeft: PREVIEW_MAX_LINES, bytesLeft: PREVIEW_MAX_BYTES, truncated: false };
}
/**
* Truncate `s` to whole characters that fit within `maxBytes` UTF-8 bytes
* (never splitting a multi-byte codepoint). Returns whether it was clamped.
*/
function clampBytes(s: string, maxBytes: number): { text: string; clamped: boolean } {
if (byteLen(s) <= maxBytes) return { text: s, clamped: false };
let out = '';
let used = 0;
for (const ch of s) {
const b = byteLen(ch);
if (used + b > maxBytes) break;
out += ch;
used += b;
}
return { text: out, clamped: true };
}
// ── Bash → command preview ───────────────────────────────────────────────────────
function deriveCommand(input: Record<string, unknown>): ApprovalPreview | null {
const command = input['command'];
if (typeof command !== 'string') return null;
const rawLines = splitLines(command);
const lineCountTruncated = rawLines.length > PREVIEW_MAX_LINES;
let anyClipped = false;
const joined = rawLines
.slice(0, PREVIEW_MAX_LINES)
.map((l) => {
const r = sanitizeLine(l);
if (r.clipped) anyClipped = true;
return r.text;
})
.join('\n');
const { text, clamped } = clampBytes(joined, PREVIEW_MAX_BYTES);
const truncated = lineCountTruncated || anyClipped || clamped;
return { kind: 'command', text, ...(truncated ? { truncated: true } : {}) };
}
// ── Edit-family → diff preview ───────────────────────────────────────────────────
/** One hunk's worth of source lines (still raw, unsanitized). */
interface HunkSpec {
removed: string[];
added: string[];
}
/** Pick a display path from the tool_input, or '' when none is present. */
function pickPath(input: Record<string, unknown>): string {
const fp = input['file_path'];
if (typeof fp === 'string') return fp;
const np = input['notebook_path'];
if (typeof np === 'string') return np;
return '';
}
/** Turn an {old_string,new_string} object into a HunkSpec. Requires BOTH fields
* to be strings (an Edit missing old_string / new_string → null → drop). */
function toHunkSpec(v: unknown): HunkSpec | null {
const rec = asRecord(v);
if (rec === null) return null;
const oldStr = rec['old_string'];
const newStr = rec['new_string'];
if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
return { removed: splitLines(oldStr), added: splitLines(newStr) };
}
/** Emit a bounded array of DiffLines from raw source lines, honouring the budget. */
function emitLines(
kind: DiffLine['kind'],
rawLines: readonly string[],
budget: Budget,
): DiffLine[] {
const out: DiffLine[] = [];
for (const raw of rawLines) {
if (budget.linesLeft <= 0) {
budget.truncated = true;
break;
}
const { text, clipped } = sanitizeLine(raw);
if (clipped) budget.truncated = true;
const b = byteLen(text);
if (b > budget.bytesLeft) {
budget.truncated = true;
break;
}
out.push({ kind, text });
budget.linesLeft -= 1;
budget.bytesLeft -= b;
}
return out;
}
/** Build a synthetic DiffFile preview from one-or-more hunk specs. */
function buildDiffPreview(rawPath: string, specs: readonly HunkSpec[]): ApprovalPreview {
const path = sanitizeLine(rawPath).text;
let addedTotal = 0;
let removedTotal = 0;
for (const s of specs) {
addedTotal += s.added.length;
removedTotal += s.removed.length;
}
const budget = newBudget();
const hunks: DiffHunk[] = [];
for (let i = 0; i < specs.length; i++) {
const spec = specs[i]!;
const lines = [
...emitLines('removed', spec.removed, budget),
...emitLines('added', spec.added, budget),
];
if (lines.length > 0) {
hunks.push({ header: specs.length > 1 ? `edit ${i + 1}` : '', lines });
}
if (budget.truncated) break;
}
const file: DiffFile = {
oldPath: path,
newPath: path,
status: removedTotal === 0 ? 'added' : 'modified',
added: addedTotal,
removed: removedTotal,
binary: false,
hunks,
};
return { kind: 'diff', file, ...(budget.truncated ? { truncated: true } : {}) };
}
function deriveDiff(toolName: string, input: Record<string, unknown>): ApprovalPreview | null {
const path = pickPath(input);
if (toolName === 'MultiEdit') {
const edits = input['edits'];
if (!Array.isArray(edits)) return null;
const specs = edits.map(toHunkSpec).filter((s): s is HunkSpec => s !== null);
if (specs.length === 0) return null;
return buildDiffPreview(path, specs);
}
if (toolName === 'Write') {
const content = input['content'];
if (typeof content !== 'string') return null;
return buildDiffPreview(path, [{ removed: [], added: splitLines(content) }]);
}
if (toolName === 'NotebookEdit') {
const source = input['new_source'];
if (typeof source !== 'string') return null;
return buildDiffPreview(path, [{ removed: [], added: splitLines(source) }]);
}
// Edit: a single {old_string,new_string} hunk.
const spec = toHunkSpec(input);
if (spec === null) return null;
return buildDiffPreview(path, [spec]);
}
// ── public entry point ───────────────────────────────────────────────────────────
/**
* Derive a bounded approval preview from a hook tool_input, or null when the
* tool is not previewable or the input is malformed. NEVER throws (SEC-M7).
*/
export function deriveApprovalPreview(
toolName: string | undefined,
toolInput: unknown,
): ApprovalPreview | null {
try {
if (toolName === undefined) return null;
const input = asRecord(toolInput);
if (input === null) return null;
if (toolName === 'Bash') return deriveCommand(input);
if (EDIT_TOOLS.has(toolName)) return deriveDiff(toolName, input);
return null;
} catch {
// SEC-M7: any unexpected shape must degrade to "no preview", never crash.
return null;
}
}

146
src/http/auth.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* src/http/auth.ts — optional shared access-token gate (w5-access-token).
*
* Pure, dependency-light helpers (mirrors the shape/discipline of
* src/http/origin.ts). This layer is **ADDITIVE**: it sits *in front of* the
* existing Origin/CSWSH defence (src/http/origin.ts) and CSRF guard
* (`requireAllowedOrigin` in src/server.ts) — it never replaces or weakens them.
*
* When `WEBTERM_TOKEN` is unset/empty the whole gate is DISABLED — every helper
* that consults `cfg.webtermToken` short-circuits and the server wiring calls
* `next()` unconditionally, so behaviour is byte-identical to today (LAN
* zero-config preserved). The gate activates only when a token is configured.
*
* ── HONEST SECURITY BOUNDARY (read before trusting this) ────────────────────
* This is a **bar-raiser, NOT a TLS/Tailscale substitute.** On a bare-LAN
* `ws://`/`http://` deployment the terminal stream — and therefore this cookie
* and token — travel in CLEARTEXT. Anyone sniffing the LAN sees the token and
* can REPLAY it (there is no per-request nonce, no channel binding). The token
* only meaningfully hardens the relay/tunnel path, where the edge terminates
* TLS and the browser speaks `wss://`/`https://`. It is a single shared secret:
* no per-user identity, no revocation except changing the env var + restarting,
* no lockout beyond rate-limiting. Do NOT read it as "safe on the public
* internet" — the TECH_DOC §7 "never port-forward this raw" guidance stands.
*/
import { createHash, timingSafeEqual } from 'node:crypto'
import type { Config } from '../types.js'
/** The auth cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate). */
export const AUTH_COOKIE_NAME = 'webterm_auth'
/** Cookie lifetime (seconds). Shipped as a constant (YAGNI — no env dial in v1);
* 30 days balances "don't re-auth every session" against a bounded replay window. */
const COOKIE_TTL_SEC = 30 * 24 * 60 * 60 // 2592000 (30 days)
/** Minimal structural view of an incoming request — keeps this module free of
* Express/DOM types (a real `http.IncomingMessage`/Express `Request` is
* structurally assignable). Used only by `isHttpsRequest`. */
export interface RequestLike {
readonly headers: Readonly<Record<string, string | string[] | undefined>>
// `remoteAddress` is here only so a real `net.Socket` is structurally
// assignable (TS weak-type check needs a shared property); `encrypted` (a
// `tls.TLSSocket` field) is the one this module actually reads.
readonly socket?: { readonly encrypted?: boolean; readonly remoteAddress?: string } | undefined
}
/** True iff auth is enabled: a non-empty token is configured. Unset/empty ⇒
* DISABLED (the single central switch — no scattered `if` checks elsewhere). */
export function isAuthEnabled(cfg: Pick<Config, 'webtermToken'>): boolean {
return typeof cfg.webtermToken === 'string' && cfg.webtermToken.length > 0
}
/**
* Parse a raw `Cookie:` header into a name→value map.
* Malformed pairs (no `=`, empty name) are ignored; last write wins on dup names.
* Values are returned verbatim (NOT URL-decoded) — our token uses a cookie-safe
* charset (validated at config load) so there is nothing to decode.
*/
export function parseCookieHeader(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {}
if (header === undefined || header === '') return out
for (const part of header.split(';')) {
const eq = part.indexOf('=')
if (eq <= 0) continue // no '=' or empty name → ignore
const name = part.slice(0, eq).trim()
if (name === '') continue
out[name] = part.slice(eq + 1).trim()
}
return out
}
/**
* Constant-time string comparison (SECURITY-CRITICAL — never use `===`).
*
* Both inputs are hashed with SHA-256 to a FIXED 32 bytes, then compared with
* `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard":
* it removes the length side-channel (unequal-length secrets no longer leak
* their length via timing) AND sidesteps `timingSafeEqual`'s throw-on-length-
* mismatch. A present-vs-absent (empty/undefined) candidate short-circuits to
* `false` before the comparator — a missing value is not a secret-compare oracle,
* not a timing signal to protect.
*/
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
// Boundary guard (input validation): never trust the candidate. An empty or
// absent value can never be a match and must not reach the comparator.
if (typeof a !== 'string' || typeof b !== 'string') return false
if (a.length === 0 || b.length === 0) return false
const ha = createHash('sha256').update(a, 'utf8').digest()
const hb = createHash('sha256').update(b, 'utf8').digest()
return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
}
/**
* True iff the request's cookie carries a valid auth token.
* Disabled config (no token) ⇒ `false` — callers MUST short-circuit on
* `isAuthEnabled` first; a disabled gate never treats anyone as "authed".
*/
export function cookieIsAuthed(
cfg: Pick<Config, 'webtermToken'>,
cookieHeader: string | undefined,
): boolean {
const token = cfg.webtermToken
if (typeof token !== 'string' || token.length === 0) return false
const presented = parseCookieHeader(cookieHeader)[AUTH_COOKIE_NAME]
return constantTimeEqual(presented, token)
}
/**
* Build the `Set-Cookie` header value for a successful auth.
*
* Flags: `HttpOnly` (XSS can't read it), `SameSite=Strict` (cross-site pages
* can't ride the cookie — complements the Origin/CSWSH defence), `Path=/`,
* `Max-Age=<ttl>`, and `Secure` **only when `opts.secure`**. The dynamic
* `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`,
* so forcing it would silently break LAN-over-HTTP auth; over the relay
* (`x-forwarded-proto: https`) it must be present.
*/
export function buildSetCookie(
cfg: Pick<Config, 'webtermToken'>,
opts: { secure: boolean },
): string {
const parts = [
`${AUTH_COOKIE_NAME}=${cfg.webtermToken ?? ''}`,
'Path=/',
`Max-Age=${COOKIE_TTL_SEC}`,
'HttpOnly',
'SameSite=Strict',
]
if (opts.secure) parts.push('Secure')
return parts.join('; ')
}
/**
* True iff the request arrived over HTTPS/WSS — either directly
* (`socket.encrypted`) or behind a TLS-terminating edge that set
* `x-forwarded-proto: https` (the relay/tunnel path). Drives the dynamic
* `Secure` cookie flag above.
*/
export function isHttpsRequest(req: RequestLike): boolean {
const xfp = req.headers['x-forwarded-proto']
const proto = Array.isArray(xfp) ? xfp[0] : xfp
if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') {
return true
}
return req.socket?.encrypted === true
}

View File

@@ -15,8 +15,13 @@
* - diff content is carried verbatim in DiffLine.text — the FE renders it as
* inert text (AC-B1.4), never HTML.
*
* FR-B1.9 (`?base=<rev>`) is intentionally deferred to P2 (review #13): it needs
* a `git rev-parse --verify` allow-list before any revision reaches the CLI.
* FR-B1.9 (`?base=<rev>`) — diff a whole branch against a base commit-ish. The
* mitigation is a two-stage revision allow-list applied BEFORE any revision
* reaches the diff CLI: (1) `isPlausibleRev` — a pure syntactic boundary check
* (rejects flag-injection / `..` ranges / metachars); (2) `git rev-parse --verify`
* — git itself is the authoritative allow-list, and only its canonical sha output
* is passed to `git diff <sha>... --`, fully decoupling the raw user string from
* the diff invocation.
*/
import { execFile } from 'node:child_process'
@@ -33,6 +38,20 @@ import type {
const execFileAsync = promisify(execFile)
// ── base-revision allow-list (pure) ─────────────────────────────────────────
/** First-stage syntactic gate for a user-supplied `?base=<rev>` (FR-B1.9). A
* plausible commit-ish starts with an alphanumeric and uses only the safe git
* ref charset; this rejects flag injection (leading `-`), `..` ranges,
* whitespace and shell metacharacters BEFORE any git call. It is NOT a full
* ref validator — `git rev-parse --verify` (resolveBaseRev) is the
* authoritative allow-list; this only fails obvious junk fast. Never throws. */
export function isPlausibleRev(base: string): boolean {
if (typeof base !== 'string') return false
if (base.includes('..')) return false // block A..B / A...B ranges
return /^[A-Za-z0-9][A-Za-z0-9._/@^~{}-]{0,249}$/.test(base)
}
// ── numstat (pure) ──────────────────────────────────────────────────────────
/** One `git diff --numstat` row: `<added>\t<removed>\t<path>`; binary = `-\t-`. */
@@ -259,6 +278,9 @@ export function parseUnifiedDiff(patch: string, numstat?: Map<string, NumstatEnt
/** Just the diff limits getDiff needs; the full Config satisfies this Pick. */
export interface GetDiffOptions {
staged: boolean
/** When set, diff the current HEAD against this base commit-ish (three-dot).
* Wins over `staged`; untracked files are not listed. Guarded by rev-parse. */
base?: string
cfg: Pick<Config, 'diffTimeoutMs' | 'diffMaxBytes' | 'diffMaxFiles'>
}
@@ -339,16 +361,85 @@ async function listUntracked(cwd: string, timeoutMs: number, maxBytes: number):
return files
}
/** Cap the file list at diffMaxFiles, propagating a truncation flag (DoS bound). */
function boundFiles(
files: readonly DiffFile[],
diffMaxFiles: number,
truncated: boolean,
): { files: DiffFile[]; truncated: boolean } {
if (files.length > diffMaxFiles) return { files: files.slice(0, diffMaxFiles), truncated: true }
return { files: [...files], truncated }
}
/**
* Read a repo's diff (working tree or `--staged`) as structured DiffResult.
* `repoPath` must already be a validated absolute git directory (route layer,
* SEC-H7). Best-effort: git failures yield an empty result rather than throwing.
* Resolve a user-supplied base revision to a canonical sha, or null (FR-B1.9).
* Two-stage allow-list: `isPlausibleRev` (defense-in-depth — the route also
* guards) then `git rev-parse --verify --quiet --end-of-options <base>^{commit}`.
* Only a `[0-9a-f]{7,64}` sha is accepted; anything else (unknown ref, non-commit
* peel, junk) → null. Never throws. The raw `base` is never interpolated: it is
* a single argv element after `--end-of-options`, and only the sha reaches diff.
*/
async function resolveBaseRev(
cwd: string,
base: string,
timeoutMs: number,
maxBytes: number,
): Promise<string | null> {
if (!isPlausibleRev(base)) return null
const { out } = await runGit(
cwd,
['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`],
timeoutMs,
maxBytes,
)
const sha = out.trim()
return /^[0-9a-f]{7,64}$/.test(sha) ? sha : null
}
/**
* Diff the current HEAD against a base commit-ish, three-dot (`<base>...HEAD` —
* the changes introduced on this branch since it diverged from base, matching a
* PR view). `base` is echoed on the result; untracked files are NOT listed.
* Best-effort: an unresolvable base → empty result rather than throwing.
*/
async function getBaseDiff(
repoPath: string,
base: string,
timeout: number,
maxBytes: number,
diffMaxFiles: number,
): Promise<DiffResult> {
const resolved = await resolveBaseRev(repoPath, base, timeout, maxBytes)
if (resolved === null) return { files: [], staged: false, truncated: false, base }
const range = `${resolved}...` // <sha>...HEAD; trailing `--` terminates options
const patch = await runGit(repoPath, ['diff', '--no-color', range, '--'], timeout, maxBytes)
const num = await runGit(repoPath, ['diff', '--numstat', range, '--'], timeout, maxBytes)
const files = parseUnifiedDiff(patch.out, parseNumstat(num.out))
const { files: bounded, truncated } = boundFiles(
files,
diffMaxFiles,
patch.truncated || num.truncated,
)
return { files: bounded, staged: false, truncated, base }
}
/**
* Read a repo's diff (working tree, `--staged`, or against a `base` revision) as
* a structured DiffResult. `repoPath` must already be a validated absolute git
* directory (route layer, SEC-H7). `opts.base` (when set) wins over `staged`.
* Best-effort: git failures yield an empty result rather than throwing.
*/
export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<DiffResult> {
const { staged, cfg } = opts
const { staged, base, cfg } = opts
const { diffTimeoutMs: timeout, diffMaxBytes: maxBytes, diffMaxFiles } = cfg
const stagedArg = staged ? ['--staged'] : []
if (base !== undefined) {
return getBaseDiff(repoPath, base, timeout, maxBytes, diffMaxFiles)
}
const stagedArg = staged ? ['--staged'] : []
const patch = await runGit(repoPath, ['diff', '--no-color', ...stagedArg, '--'], timeout, maxBytes)
const num = await runGit(repoPath, ['diff', '--numstat', ...stagedArg, '--'], timeout, maxBytes)
@@ -357,9 +448,10 @@ export async function getDiff(repoPath: string, opts: GetDiffOptions): Promise<D
files.push(...(await listUntracked(repoPath, timeout, maxBytes)))
}
let truncated = patch.truncated || num.truncated
const bounded =
files.length > diffMaxFiles ? ((truncated = true), files.slice(0, diffMaxFiles)) : files
const { files: bounded, truncated } = boundFiles(
files,
diffMaxFiles,
patch.truncated || num.truncated,
)
return { files: bounded, staged, truncated }
}

79
src/http/digest.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* src/http/digest.ts (W3 quick-wins c) — "while you were away" reconnect digest.
*
* A PURE read-side aggregate over the live-session list (injected, like
* buildProjects) plus each session's in-memory telemetry/status. No new state:
* it only projects what the manager already tracks into a compact summary the FE
* shows as a banner on (re)connect.
*
* `since` is a client's last-seen epoch-ms watermark: a session counts as
* `finished` when it is idle AND produced output after `since`. A bad/absent
* `since` clamps to 0 ("everything is new").
*/
import path from 'node:path'
import type { DigestResult, DigestSession, LiveSessionInfo } from '../types.js'
/** Last path segment of a cwd (the session "title"), or undefined. */
function lastSegment(cwd: string | null): string | undefined {
if (cwd === null || cwd === '') return undefined
return cwd.split(path.sep).filter(Boolean).pop()
}
/** Clamp `since` to a finite, non-negative number (bad/absent → 0). */
export function clampSince(since: unknown): number {
const n = typeof since === 'number' ? since : Number(since)
return Number.isFinite(n) && n >= 0 ? n : 0
}
/** Project one live session into its digest row. */
function toDigestSession(s: LiveSessionInfo, since: number): DigestSession {
const title = lastSegment(s.cwd)
const costUsd = s.telemetry?.costUsd
const lastOutputAt = s.lastOutputAt
const finished = s.status === 'idle' && lastOutputAt !== undefined && lastOutputAt > since
return {
id: s.id,
...(title !== undefined ? { title } : {}),
status: s.status,
...(costUsd !== undefined ? { costUsd } : {}),
...(lastOutputAt !== undefined ? { lastOutputAt } : {}),
finished,
needsInput: s.status === 'waiting',
stuck: s.status === 'stuck',
}
}
/**
* Build the reconnect digest from the live-session list. Pure — the list is
* injected (e.g. `manager.list()`). Empty list → all-zero aggregate. Never throws.
*/
export function buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult {
const clampedSince = clampSince(since)
const sessions = live.map((s) => toDigestSession(s, clampedSince))
let finished = 0
let needsInput = 0
let stuck = 0
let working = 0
let totalCostUsd = 0
for (const d of sessions) {
if (d.finished) finished += 1
if (d.needsInput) needsInput += 1
if (d.stuck) stuck += 1
if (d.status === 'working') working += 1
if (d.costUsd !== undefined) totalCostUsd += d.costUsd
}
return {
since: clampedSince,
generatedAt: Date.now(),
total: sessions.length,
finished,
needsInput,
stuck,
working,
totalCostUsd,
sessions,
}
}

View File

@@ -61,3 +61,86 @@ export async function openInEditor(cfg: Config, rawPath: unknown): Promise<OpenE
return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
}
}
// ── W1: open a specific FILE at a line (clickable terminal paths) ──────────────
//
// Additive to openInEditor (which only opens directories). The frontend link
// provider POSTs {file, line} here so a click on `src/app.ts:42` jumps straight
// to that line in the host's editor. Same execFile (no shell) safety model.
/** Editors whose CLI accepts `--goto <file>:<line>` to jump to a line. */
const GOTO_EDITORS: ReadonlySet<string> = new Set([
'code', 'code-insiders', 'codium', 'vscodium', 'cursor', 'windsurf',
])
const MIN_LINE = 1
const MAX_LINE = 1_000_000
/**
* True iff `editorCmd`'s basename is an editor that understands `--goto file:line`.
* Unknown editors get the bare file (never a stray `--goto` argv they'd misread
* as a filename). A trailing .cmd/.exe/.bat (Windows shim) is stripped first.
*/
export function isGotoEditor(editorCmd: string): boolean {
const base = path.basename(editorCmd).toLowerCase().replace(/\.(cmd|exe|bat)$/, '')
return GOTO_EDITORS.has(base)
}
/**
* Validate `rawFile` (absolute, existing regular file) and optional `rawLine`
* (integer 1..1_000_000), then launch the editor at that file:line. Never throws
* — returns a structured result the route maps to an HTTP response. The line is
* validated to an integer before interpolation into `${file}:${line}`, so it can
* never smuggle shell metacharacters (and there's no shell anyway — execFile argv).
*/
export async function openFileInEditor(
cfg: Config,
rawFile: unknown,
rawLine?: unknown,
): Promise<OpenEditorResult> {
if (typeof rawFile !== 'string' || rawFile.trim() === '') {
return { ok: false, status: 400, error: 'file is required' }
}
if (!path.isAbsolute(rawFile)) {
return { ok: false, status: 400, error: 'file must be absolute' }
}
let line: number | undefined
if (rawLine !== undefined && rawLine !== null) {
if (
typeof rawLine !== 'number' ||
!Number.isInteger(rawLine) ||
rawLine < MIN_LINE ||
rawLine > MAX_LINE
) {
return { ok: false, status: 400, error: `line must be an integer ${MIN_LINE}..${MAX_LINE}` }
}
line = rawLine
}
let stat
try {
stat = await fs.stat(rawFile)
} catch {
return { ok: false, status: 404, error: 'file not found' }
}
if (!stat.isFile()) {
return { ok: false, status: 400, error: 'path is not a file' }
}
const args =
isGotoEditor(cfg.editorCmd) && line !== undefined
? ['--goto', `${rawFile}:${line}`]
: [rawFile]
try {
const child = execFile(cfg.editorCmd, args, { windowsHide: true })
child.on('error', (err) => {
console.error('[editor] failed to launch', JSON.stringify(cfg.editorCmd), '-', err.message)
})
child.unref()
return { ok: true, status: 204 }
} catch (err) {
return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
}
}

316
src/http/gh.ts Normal file
View File

@@ -0,0 +1,316 @@
/**
* src/http/gh.ts (W3 PR + CI status chip) — read-only PR / CI status via `gh`.
*
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
* `gh pr view --json …` in a repo directory and PARSES its JSON into a PrStatus.
* Structure mirrors src/http/diff.ts:
* - runGh: execFile('gh', [fixed argv]) — NO shell; timeout + maxBuffer bound
* DoS (SEC-M9). The ONLY user-influenced input reaching gh is `cwd`, the
* already-validated repoPath (route layer, SEC-H7). No untrusted string ever
* enters argv — gh derives the PR from the current branch.
* - pure, exported: parsePrView / summarizeChecks / classifyGhFailure. They
* NEVER throw: malformed / unknown input degrades to {availability:'error'}.
* - getPrStatus: module-scope short-TTL cache (cfg.projectScanTtlMs) keyed by
* repoPath + current branch (so a branch switch busts before TTL), with
* in-flight dedupe (cache-stampede guard) — caps outbound GitHub-API calls.
*
* Network egress note: unlike every other side-channel (all local), gh talks to
* GitHub's API using the host's existing gh / GH_TOKEN credential. This module
* NEVER accepts or forwards a token — it only triggers gh's own auth. It also
* NEVER logs gh stdout (private PR titles) or the token.
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Config, PrAvailability, PrCheckSummary, PrStatus } from '../types.js'
const execFileAsync = promisify(execFile)
/** One `gh pr view` spawn: exactly the fields parsePrView / summarizeChecks read. */
const PR_VIEW_FIELDS =
'number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup'
/** Config subset getPrStatus needs; the full Config satisfies this Pick. */
export type GhOptions = Pick<Config, 'ghEnabled' | 'ghTimeoutMs' | 'projectScanTtlMs' | 'diffMaxBytes'>
// ── gh runner (injectable seam — default real, overridable in tests) ──────────
/** Normalized outcome of one gh spawn. `code` carries a spawn/system error code
* ('ENOENT', 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') or the numeric exit code. */
export interface GhExecResult {
ok: boolean // process exited 0
stdout: string
stderr: string
code?: string | number
}
/** A gh runner: same seam idea as getDiff's runGit, so getPrStatus is unit-testable
* without spawning gh. */
export type GhRunner = (
repoPath: string,
args: readonly string[],
timeoutMs: number,
maxBytes: number,
) => Promise<GhExecResult>
function asString(v: unknown): string {
return typeof v === 'string' ? v : ''
}
/**
* Real gh runner: execFile('gh', [...]) with NO shell, bounded by timeout +
* maxBuffer. A spawn ENOENT (gh not installed), a non-zero exit (unauth / no PR),
* a timeout, or a maxBuffer overflow all resolve (never reject) with ok:false so
* the classifier can degrade — mirroring diff.ts's best-effort house style.
*/
async function realRunGh(
repoPath: string,
args: readonly string[],
timeoutMs: number,
maxBytes: number,
): Promise<GhExecResult> {
try {
const { stdout, stderr } = await execFileAsync('gh', [...args], {
cwd: repoPath,
timeout: timeoutMs,
maxBuffer: maxBytes,
})
return { ok: true, stdout, stderr, code: 0 }
} catch (err: unknown) {
const e = err as { code?: unknown; stdout?: unknown; stderr?: unknown }
const code =
typeof e.code === 'string' || typeof e.code === 'number' ? e.code : undefined
return { ok: false, stdout: asString(e.stdout), stderr: asString(e.stderr), code }
}
}
// ── classifyGhFailure (pure) ──────────────────────────────────────────────────
/** A gh non-zero / spawn failure, reduced to what the classifier reads. */
export interface GhFailure {
code?: string | number
stderr: string
}
/**
* Map a gh failure to a degrade reason (regex on lower-cased stderr). ENOENT ⇒
* gh isn't installed; an auth pattern ⇒ not logged in; a "no PR / no remote"
* pattern ⇒ no PR for the branch; anything else (timeout, maxBuffer overflow,
* unknown) ⇒ generic error. Never throws.
*/
export function classifyGhFailure(f: GhFailure): PrAvailability {
if (f.code === 'ENOENT') return 'not-installed'
const s = asString(f.stderr).toLowerCase()
if (/gh auth login|not logged|authentication|http 401/.test(s)) return 'unauthenticated'
if (/no pull requests found|no default remote|no git remote/.test(s)) return 'no-pr'
return 'error'
}
// ── summarizeChecks (pure) ────────────────────────────────────────────────────
type CheckBucket = 'passing' | 'failing' | 'pending'
const CHECKRUN_PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
const CHECKRUN_FAIL = new Set([
'FAILURE',
'TIMED_OUT',
'CANCELLED',
'ACTION_REQUIRED',
'STARTUP_FAILURE',
'STALE',
])
const CONTEXT_FAIL = new Set(['FAILURE', 'ERROR'])
/** A CheckRun's status/conclusion → bucket. A non-COMPLETED status (QUEUED,
* IN_PROGRESS, WAITING, PENDING, REQUESTED) is pending regardless of conclusion;
* a completed/absent status is bucketed by conclusion (null/unknown → pending). */
function bucketCheckRun(status: unknown, conclusion: unknown): CheckBucket {
const st = asString(status).toUpperCase()
if (st !== '' && st !== 'COMPLETED') return 'pending'
const c = asString(conclusion).toUpperCase()
if (CHECKRUN_PASS.has(c)) return 'passing'
if (CHECKRUN_FAIL.has(c)) return 'failing'
return 'pending'
}
/** A StatusContext's state → bucket (SUCCESS pass, FAILURE/ERROR fail, else pending). */
function bucketContext(state: unknown): CheckBucket {
const s = asString(state).toUpperCase()
if (s === 'SUCCESS') return 'passing'
if (CONTEXT_FAIL.has(s)) return 'failing'
return 'pending' // PENDING, EXPECTED, unknown
}
/** Classify one rollup item. Prefers __typename, else infers from present keys.
* Anything unrecognized still counts toward total, treated as pending. */
function bucketItem(raw: unknown): CheckBucket {
if (raw === null || typeof raw !== 'object') return 'pending'
const o = raw as Record<string, unknown>
const typename = asString(o['__typename'])
const isContext =
typename === 'StatusContext' ||
(typename !== 'CheckRun' &&
o['state'] !== undefined &&
o['status'] === undefined &&
o['conclusion'] === undefined)
if (isContext) return bucketContext(o['state'])
if (typename === 'CheckRun' || o['status'] !== undefined || o['conclusion'] !== undefined) {
return bucketCheckRun(o['status'], o['conclusion'])
}
return 'pending'
}
/**
* Roll up gh's `statusCheckRollup` (a mix of CheckRun + StatusContext items) into
* total / passing / failing / pending counts. Non-array / empty / undefined ⇒
* all-zero. Every item lands in exactly one bucket so total === pass+fail+pending.
* Never throws.
*/
export function summarizeChecks(rollup: unknown): PrCheckSummary {
const summary: PrCheckSummary = { total: 0, passing: 0, failing: 0, pending: 0 }
if (!Array.isArray(rollup)) return summary
for (const item of rollup) {
summary.total += 1
summary[bucketItem(item)] += 1
}
return summary
}
// ── parsePrView (pure) ────────────────────────────────────────────────────────
function mapState(v: unknown): PrStatus['state'] {
if (typeof v !== 'string') return undefined
const s = v.toLowerCase()
return s === 'open' || s === 'closed' || s === 'merged' ? s : undefined
}
function mapMergeable(v: unknown): PrStatus['mergeable'] {
if (typeof v !== 'string') return undefined
const s = v.toLowerCase()
return s === 'mergeable' || s === 'conflicting' || s === 'unknown' ? s : undefined
}
/**
* Parse `gh pr view --json …` output into a PrStatus. Valid PR JSON ⇒
* availability:'ok' with lower-cased state/mergeable and a checks summary; a
* malformed / non-object payload ⇒ {availability:'error'} (never throws — the
* diff.ts "never throws" house style). Attacker-controllable strings (title/url)
* are carried VERBATIM — the FE renders them inert via textContent (SEC-H4).
*/
export function parsePrView(json: string): PrStatus {
let raw: unknown
try {
raw = JSON.parse(json)
} catch {
return { availability: 'error' }
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return { availability: 'error' }
}
const o = raw as Record<string, unknown>
const status: PrStatus = { availability: 'ok' }
if (typeof o['number'] === 'number') status.number = o['number']
if (typeof o['title'] === 'string') status.title = o['title']
if (typeof o['url'] === 'string') status.url = o['url']
const state = mapState(o['state'])
if (state !== undefined) status.state = state
if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
const mergeable = mapMergeable(o['mergeable'])
if (mergeable !== undefined) status.mergeable = mergeable
if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
status.checks = summarizeChecks(o['statusCheckRollup'])
return status
}
// ── current-branch read (cheap; cache-key input) ──────────────────────────────
/** Current branch from `<repo>/.git/HEAD` (ref line only). Detached HEAD / junk /
* unreadable ⇒ null. Cheap: no spawn (mirrors projects.ts readBranch/parseGitHead). */
async function readHeadBranch(repoPath: string): Promise<string | null> {
try {
const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
const match = /^ref:\s+refs\/heads\/(.+)$/.exec(head.trim())
const branch = match?.[1]?.trim()
return branch !== undefined && branch !== '' ? branch : null
} catch {
return null
}
}
// ── getPrStatus (cached runner) ───────────────────────────────────────────────
interface PrCacheEntry {
expiresAt: number
status: PrStatus
}
const prCache = new Map<string, PrCacheEntry>()
/** Shared in-flight promises so concurrent cache-miss callers join one gh run. */
const inflightPr = new Map<string, Promise<PrStatus>>()
/** repoPath + branch: a branch switch changes the key, busting stale PR data. */
function cacheKey(repoPath: string, branch: string | null): string {
return `${repoPath}\n${branch ?? ''}`
}
async function runPrStatus(
repoPath: string,
cfg: GhOptions,
runner: GhRunner,
): Promise<PrStatus> {
const exec = await runner(
repoPath,
['pr', 'view', '--json', PR_VIEW_FIELDS],
cfg.ghTimeoutMs,
cfg.diffMaxBytes,
)
if (exec.ok) return parsePrView(exec.stdout)
return { availability: classifyGhFailure({ code: exec.code, stderr: exec.stderr }) }
}
/**
* Read a repo's PR + CI status. `repoPath` must already be a validated absolute
* git directory (route layer, SEC-H7). Best-effort: gh missing / unauthed / no PR
* / timeout all resolve to a PrStatus whose `availability` names the reason —
* NEVER throws, never blocks the caller. Cached at module scope with
* cfg.projectScanTtlMs and in-flight dedupe. `runner` is injectable for tests.
*/
export async function getPrStatus(
repoPath: string,
cfg: GhOptions,
runner: GhRunner = realRunGh,
): Promise<PrStatus> {
if (!cfg.ghEnabled) return { availability: 'disabled' }
const branch = await readHeadBranch(repoPath)
const key = cacheKey(repoPath, branch)
const now = Date.now()
const cached = prCache.get(key)
if (cached !== undefined && cached.expiresAt > now) return cached.status
const inflight = inflightPr.get(key)
if (inflight !== undefined) return inflight
const promise = runPrStatus(repoPath, cfg, runner)
.then((status) => {
prCache.set(key, { expiresAt: Date.now() + cfg.projectScanTtlMs, status })
inflightPr.delete(key)
return status
})
.catch((e: unknown) => {
inflightPr.delete(key)
throw e
})
inflightPr.set(key, promise)
return promise
}
/** Test-only: drop the PR cache and any in-flight run so each test sees a fresh gh call. */
export function _clearPrCache(): void {
prCache.clear()
inflightPr.clear()
}

109
src/http/git-log.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* src/http/git-log.ts (W3 quick-wins d) — read-only recent-commit log.
*
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
* `git log` in a directory and PARSES its output into CommitLogEntry[]. Parsing
* lives ONLY here; public/git-log.ts is render-only (mirrors diff.ts / gh.ts).
*
* Delimiter design (robust against nasty subjects): the format is
* %h %x1f %ct %x1f %s with -z (records separated by NUL)
* so a US (0x1f) field separator + a NUL (0x00) record separator can never be
* corrupted by a subject containing tabs, spaces or newlines. We never parse the
* fragile `--oneline` shape.
*
* Security (mirrors diff.ts):
* - execFile('git', [...]) with NO shell; timeout + maxBuffer bound DoS.
* - `repoPath` is the cwd, never interpolated into argv; `n` is coerced to an
* int and clamped BEFORE reaching argv. Path→repo validation is the route's
* job (isValidGitDir, SEC-H7), exactly like /projects/diff.
* - parseGitLog NEVER throws: malformed records are skipped; getGitLog is
* best-effort and returns an empty result rather than rejecting.
* - subjects are carried verbatim and rendered as inert text (textContent) in
* the FE — never HTML.
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { CommitLogEntry, GitLogResult } from '../types.js'
const execFileAsync = promisify(execFile)
/** Hard cap on the number of commits a single request may return (DoS bound). */
export const GIT_LOG_MAX = 50
/** Default number of commits when the caller does not specify `n`. */
export const GIT_LOG_DEFAULT = 20
/** Cap a single commit subject so a pathological message can't bloat the payload. */
const SUBJECT_MAX_LEN = 500
/** Bound the captured stdout (DoS guard); 1 MB is ample for ≤50 subjects. */
const GIT_LOG_MAX_BUFFER = 1024 * 1024
/** Field separator (US, 0x1f) and record separator (NUL, 0x00). */
const FIELD_SEP = '\x1f'
const RECORD_SEP = '\x00'
/**
* Clamp a possibly-junk `n` to an integer in [1, GIT_LOG_MAX]. Non-numeric /
* missing → GIT_LOG_DEFAULT. Never throws.
*/
export function clampLogCount(n: unknown): number {
const parsed = typeof n === 'number' ? n : Number.parseInt(String(n ?? ''), 10)
if (!Number.isFinite(parsed)) return GIT_LOG_DEFAULT
const floored = Math.floor(parsed)
if (floored < 1) return 1
if (floored > GIT_LOG_MAX) return GIT_LOG_MAX
return floored
}
/**
* Parse `git log -z --format=%h%x1f%ct%x1f%s` output into CommitLogEntry[].
* Records are NUL-separated; fields are US-separated. A record missing a field
* or with a non-numeric timestamp is skipped (never throws). `max` caps the
* returned entries and drives the `truncated` flag (records === max ⇒ there may
* be more). Empty stdout → an empty, non-truncated result.
*/
export function parseGitLog(stdout: string, max: number): GitLogResult {
if (typeof stdout !== 'string' || stdout.length === 0) {
return { commits: [], truncated: false }
}
const records = stdout.split(RECORD_SEP).filter((r) => r.length > 0)
const commits: CommitLogEntry[] = []
for (const record of records) {
const parts = record.split(FIELD_SEP)
if (parts.length < 3) continue // malformed — missing a field
const hash = (parts[0] ?? '').trim()
const secs = Number.parseInt(parts[1] ?? '', 10)
// Subject may (in theory) contain a US char; re-join the tail so it is intact.
const subject = parts.slice(2).join(FIELD_SEP)
if (hash === '' || !Number.isFinite(secs) || secs < 0) continue
commits.push({
hash,
at: secs * 1000,
subject: subject.length > SUBJECT_MAX_LEN ? subject.slice(0, SUBJECT_MAX_LEN) : subject,
})
}
const truncated = commits.length >= max && max > 0
return { commits: max > 0 ? commits.slice(0, max) : commits, truncated }
}
export interface GetGitLogOptions {
n?: number
timeoutMs: number
}
/**
* Read a repo's recent commits (newest first) as a structured GitLogResult.
* `repoPath` must already be a validated absolute git directory (route layer,
* SEC-H7). Best-effort: any git failure yields an empty result, never throws.
*/
export async function getGitLog(repoPath: string, opts: GetGitLogOptions): Promise<GitLogResult> {
const n = clampLogCount(opts.n)
try {
const { stdout } = await execFileAsync(
'git',
['log', '--no-color', '-z', '-n', String(n), '--format=%h%x1f%ct%x1f%s'],
{ cwd: repoPath, timeout: opts.timeoutMs, maxBuffer: GIT_LOG_MAX_BUFFER },
)
return parseGitLog(stdout, n)
} catch {
return { commits: [], truncated: false }
}
}

361
src/http/git-ops.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* src/http/git-ops.ts (w4-commit-push) — the git WRITE engine (stage/commit/push).
*
* The highest-risk side-channel in the app: it stages files, commits, and pushes
* the current branch. It mirrors the read-only diff.ts / worktrees.ts execFile
* pattern exactly and adds three non-negotiable safety spines:
*
* 1. NO SHELL, EVER — execFile('git', argv, …) only. Untrusted strings (the
* commit message, every file path) are argv ELEMENTS, never interpolated. A
* trailing `--` terminates options before any pathspec, and file paths that
* start with `-` are rejected, so a path can never be read as a flag.
* 2. REALPATH CONTAINMENT — every file in `files[]` is realpath-resolved and
* proven inside the repo (git-path.ts, the M2 pattern). Defeats `../` and
* pre-planted symlink escapes even though the route's isValidGitDir is lighter.
* 3. SERVER-DERIVED push target — push NEVER accepts a remote or refspec from the
* client; both branch and remote are read back from the repo, and it is never
* a force-push (no `--force`, no `+refspec`).
*
* Every export never throws — failures return a structured GitOpResult with a
* SAFE message (never raw git stderr, SEC-M10), classified by classifyGitError.
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { GitOpResult } from '../types.js'
import { resolveRealPath, isContained } from './git-path.js'
const execFileAsync = promisify(execFile)
const GIT_MAX_BUFFER = 1024 * 1024
// GIT_TERMINAL_PROMPT=0 + ssh BatchMode make a credential/host-key prompt fail
// FAST (→ classified 401) instead of hanging until the exec timeout backstop.
const NO_PROMPT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_TERMINAL_PROMPT: '0',
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
}
export interface GitOpOptions {
readonly timeoutMs: number
/** Max number of files a single stage call may touch (reuses cfg.diffMaxFiles). */
readonly maxFiles?: number
}
export interface CommitOptions {
readonly timeoutMs: number
/** Commit-message length cap (cfg.commitMsgMaxLen). */
readonly maxLen: number
}
export interface PushOptions {
readonly timeoutMs: number
}
// ── error classification (pure, SEC-M10) ──────────────────────────────────────
/** Pull a best-effort diagnostic string off a child_process error (never throws).
* Combines stderr AND stdout because git writes some conditions ("nothing to
* commit") to stdout, others ("! [rejected]", auth) to stderr; message is the
* fallback. Only substrings are matched — the raw text is never surfaced. */
function extractStderr(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
const parts: string[] = []
if (typeof e.stderr === 'string') parts.push(e.stderr)
if (typeof e.stdout === 'string') parts.push(e.stdout)
if (parts.join('').trim() !== '') return parts.join('\n')
if (typeof e.message === 'string') return e.message
}
return ''
}
/**
* Map a git stderr string to a structured {status, error}. The returned `error`
* is ALWAYS a hard-coded safe phrase — raw git stderr (which can contain repo
* paths / remote URLs) is never surfaced (SEC-M10). Ordered most-specific first.
* Pure + unit-tested. Unknown → 500.
*/
export function classifyGitError(stderr: string): { status: number; error: string } {
const s = (typeof stderr === 'string' ? stderr : '').toLowerCase()
if (s.includes('index.lock') || (s.includes('unable to create') && s.includes('.lock'))) {
return { status: 409, error: 'Another git operation is in progress.' }
}
if (
s.includes('nothing to commit') ||
s.includes('nothing added to commit') ||
s.includes('no changes added to commit')
) {
return { status: 409, error: 'Nothing staged to commit.' }
}
if (s.includes('please tell me who you are')) {
return { status: 400, error: 'Set a git author identity (user.name / user.email) first.' }
}
if (s.includes('rejected') || s.includes('non-fast-forward') || s.includes('fetch first')) {
return { status: 409, error: 'Push rejected — pull or rebase first.' }
}
if (
s.includes('authentication failed') ||
s.includes('could not read from remote') ||
s.includes('could not read username') ||
s.includes('terminal prompts disabled') ||
s.includes('permission denied (publickey)') ||
s.includes('no anonymous write access')
) {
return { status: 401, error: 'Push authentication required on the host.' }
}
if (s.includes('not a git repository')) {
return { status: 404, error: 'Not a git repository.' }
}
return { status: 500, error: 'Git operation failed.' }
}
/** Wrap a caught child_process error as a failed GitOpResult (never throws). */
function failFromError(err: unknown): GitOpResult {
const { status, error } = classifyGitError(extractStderr(err))
return { ok: false, status, error }
}
// ── repo + file validation ─────────────────────────────────────────────────────
/** Three-prong entry check: absolute + isDirectory + has a `.git` entry. */
async function isGitDir(repoPath: string): Promise<boolean> {
if (typeof repoPath !== 'string' || !path.isAbsolute(repoPath)) return false
try {
const st = await fs.stat(repoPath)
if (!st.isDirectory()) return false
await fs.stat(path.join(repoPath, '.git'))
return true
} catch {
return false
}
}
/**
* Validate an untrusted `files[]` for a stage call. Returns the ACCEPTED relative
* paths (verbatim, to hand to `git <verb> -- <paths>`), or null on ANY rejection.
* `repoRealPath` MUST already be the realpath of the repo. Rejections:
* - empty list / more than `maxFiles` entries (DoS bound)
* - non-string / empty entry
* - absolute path (a stage path is always repo-relative)
* - leading '-' (flag injection — even after `--`, belt-and-braces)
* - realpath escapes the repo (../ traversal or a symlink pointing outside, M2)
* A deleted file's path (absent on disk) is fine: resolveRealPath resolves the
* existing prefix and re-appends the missing tail, still contained. Never throws.
*/
export async function validateRepoFiles(
repoRealPath: string,
files: readonly unknown[],
maxFiles: number,
): Promise<string[] | null> {
if (!Array.isArray(files) || files.length === 0) return null
if (files.length > maxFiles) return null
const accepted: string[] = []
for (const raw of files) {
if (typeof raw !== 'string' || raw.length === 0) return null
if (path.isAbsolute(raw)) return null
if (raw.startsWith('-')) return null
const realCandidate = await resolveRealPath(path.join(repoRealPath, raw))
// Must be strictly INSIDE the repo — never the repo root itself (e.g. '.').
if (realCandidate === repoRealPath || !isContained(repoRealPath, realCandidate)) {
return null
}
accepted.push(raw)
}
return accepted
}
// ── stage / unstage ─────────────────────────────────────────────────────────────
/**
* Stage (`git add`) or unstage (`git restore --staged`) the given repo-relative
* files. `stage=true` → add; `false` → restore --staged. Validates the repo and
* realpath-contains every file before running git (no shell, `--` terminates
* options). Returns {ok, staged, count} on success. Never throws.
*/
export async function stageFiles(
repoPath: string,
files: readonly unknown[],
stage: boolean,
opts: GitOpOptions,
): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
const repoReal = await resolveRealPath(repoPath)
const maxFiles = opts.maxFiles ?? 300
const valid = await validateRepoFiles(repoReal, files, maxFiles)
if (valid === null) {
return { ok: false, status: 400, error: 'Invalid file selection.' }
}
const args = stage ? ['add', '--', ...valid] : ['restore', '--staged', '--', ...valid]
try {
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
return { ok: true, staged: stage, count: valid.length }
} catch (err: unknown) {
return failFromError(err)
}
}
// ── commit ──────────────────────────────────────────────────────────────────────
/**
* Commit the STAGED changes with `message`. The message is validated (non-empty
* after trim, ≤ maxLen) and passed as the single value of `-m` via argv — never
* a shell string, never a pathspec — so newlines / leading '-' / emoji are all
* safe. Returns {ok, commit:<short sha>}. Empty index → 409, identity unset →
* 400 (classified). Never throws.
*/
export async function commit(
repoPath: string,
message: string,
opts: CommitOptions,
): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
if (typeof message !== 'string' || message.trim() === '') {
return { ok: false, status: 400, error: 'Commit message is required.' }
}
if (message.length > opts.maxLen) {
return { ok: false, status: 400, error: 'Commit message is too long.' }
}
try {
await execFileAsync('git', ['commit', '-m', message], {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
} catch (err: unknown) {
return failFromError(err)
}
// Read back the new commit's short SHA (best-effort; commit already succeeded).
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
return { ok: true, commit: stdout.trim() }
} catch {
return { ok: true, commit: '' }
}
}
// ── push ──────────────────────────────────────────────────────────────────────
/** Read the current branch, or null if git failed / detached (output 'HEAD'). */
async function currentBranch(repoPath: string, timeoutMs: number): Promise<string | null> {
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: repoPath,
timeout: timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
const branch = stdout.trim()
return branch === '' || branch === 'HEAD' ? null : branch
} catch {
return null
}
}
/** The upstream remote name (e.g. 'origin') for the current branch, or null. */
async function upstreamRemote(repoPath: string, timeoutMs: number): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
{ cwd: repoPath, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER },
)
const ref = stdout.trim() // e.g. "origin/main"
const slash = ref.indexOf('/')
return slash > 0 ? ref.slice(0, slash) : null
} catch {
return null // no upstream configured
}
}
/** List configured remotes (one per line), or [] on error. */
async function listRemotes(repoPath: string, timeoutMs: number): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', ['remote'], {
cwd: repoPath,
timeout: timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
})
return stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '')
} catch {
return []
}
}
/**
* Push the CURRENT branch. If it already has an upstream → plain `git push`
* (git pushes the current branch to its configured upstream ref; never other
* branches, never a force). If it has NO upstream: exactly one remote →
* `git push -u <remote> <branch>` (sets it); zero remotes → 400; ≥2 remotes →
* 409 (ambiguous, set an upstream first). The remote and branch are ALWAYS
* derived server-side from the repo — never taken from the client — and it is
* NEVER a force-push. Returns {ok, branch, remote}. Never throws.
*/
export async function push(repoPath: string, opts: PushOptions): Promise<GitOpResult> {
if (!(await isGitDir(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
const branch = await currentBranch(repoPath, opts.timeoutMs)
if (branch === null) {
return { ok: false, status: 400, error: 'Cannot push a detached HEAD.' }
}
// Defence in depth: a real checked-out branch never starts with '-', but a
// branch/remote that did could be read as a flag in the -u form below.
if (branch.startsWith('-')) {
return { ok: false, status: 400, error: 'Cannot push this branch.' }
}
const remote = await upstreamRemote(repoPath, opts.timeoutMs)
let args: string[]
let targetRemote: string
if (remote !== null) {
// Upstream already set → plain push (safe; no remote/refspec from client).
args = ['push']
targetRemote = remote
} else {
const remotes = await listRemotes(repoPath, opts.timeoutMs)
if (remotes.length === 0) {
return { ok: false, status: 400, error: 'No remote configured.' }
}
if (remotes.length > 1) {
return { ok: false, status: 409, error: 'Set an upstream first (multiple remotes).' }
}
const sole = remotes[0] as string
if (sole.startsWith('-')) {
return { ok: false, status: 400, error: 'Cannot push to this remote.' }
}
args = ['push', '-u', sole, branch]
targetRemote = sole
}
try {
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: GIT_MAX_BUFFER,
env: NO_PROMPT_ENV,
})
return { ok: true, branch, remote: targetRemote }
} catch (err: unknown) {
return failFromError(err)
}
}

49
src/http/git-path.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* src/http/git-path.ts (w4-commit-push) — realpath-based path containment.
*
* Extracted so the git-write engine (git-ops.ts) shares ONE containment routine
* instead of duplicating the M2 pattern from worktrees.ts. Following the plan,
* only the two primitives live here; refactoring worktrees.ts onto them is a
* deferred follow-up (out of this task's lane).
*
* Security (M2 / SEC-H3): a path is proven contained by realpath-resolving BOTH
* the base and the candidate (symlinks followed) and then doing a prefix compare.
* This defeats `../` traversal AND pre-planted-symlink escapes — the lighter
* three-prong isValidGitDir check at the route does NOT follow symlinks, so this
* is the defense-in-depth backstop for every untrusted file path.
*/
import fs from 'node:fs/promises'
import path from 'node:path'
/**
* Resolve symlinks on the longest existing prefix of `target`, re-appending any
* not-yet-existing trailing segments. Lets us canonicalise a path that may not
* exist on disk (e.g. a staged deletion whose file is already gone) without it
* having to exist. Mirrors worktrees.ts resolveRealPath (M2). Never throws.
*/
export async function resolveRealPath(target: string): Promise<string> {
let current = path.resolve(target)
const tail: string[] = []
for (;;) {
try {
const real = await fs.realpath(current)
return tail.length === 0 ? real : path.join(real, ...tail.slice().reverse())
} catch {
const parent = path.dirname(current)
if (parent === current) return path.resolve(target) // reached an unresolvable root
tail.push(path.basename(current))
current = parent
}
}
}
/**
* True iff `realCandidate` is `realBase` itself or lives strictly inside it. Both
* arguments must ALREADY be realpath-resolved (call resolveRealPath first). The
* `realBase + path.sep` guard prevents a sibling-prefix escape (`/repo-evil` is
* NOT inside `/repo`). Pure.
*/
export function isContained(realBase: string, realCandidate: string): boolean {
return realCandidate === realBase || realCandidate.startsWith(realBase + path.sep)
}

View File

@@ -108,6 +108,50 @@ async function readDirty(repoPath: string): Promise<boolean | undefined> {
}
}
/** W3 quick-wins (a): best-effort ahead/behind vs upstream + last-commit time.
* Two read-only git calls (no shell), each bounded by timeout + maxBuffer:
* 1. `git rev-list --count --left-right @{u}...HEAD` → "<behind>\t<ahead>"
* (left = commits on @{u} not HEAD = behind; right = HEAD not @{u} = ahead).
* No upstream (`@{u}` fatal) / detached / empty repo → ahead/behind undefined.
* 2. `git log -1 --format=%ct` → HEAD commit unix seconds → lastCommitMs (×1000).
* Every field degrades to undefined independently; never throws. */
async function readSync(repoPath: string): Promise<{
ahead?: number
behind?: number
lastCommitMs?: number
}> {
const out: { ahead?: number; behind?: number; lastCommitMs?: number } = {}
try {
const { stdout } = await execFileAsync(
'git',
['rev-list', '--count', '--left-right', '@{u}...HEAD'],
{ cwd: repoPath, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BUFFER },
)
const parts = stdout.trim().split(/\s+/)
const behind = Number.parseInt(parts[0] ?? '', 10)
const ahead = Number.parseInt(parts[1] ?? '', 10)
if (Number.isFinite(behind) && behind >= 0) out.behind = behind
if (Number.isFinite(ahead) && ahead >= 0) out.ahead = ahead
} catch {
// no upstream / detached / empty repo → leave ahead/behind undefined
}
try {
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%ct'], {
cwd: repoPath,
timeout: GIT_STATUS_TIMEOUT_MS,
maxBuffer: GIT_STATUS_MAX_BUFFER,
})
const secs = Number.parseInt(stdout.trim(), 10)
if (Number.isFinite(secs) && secs >= 0) out.lastCommitMs = secs * 1000
} catch {
// empty repo (no commits) → leave lastCommitMs undefined
}
return out
}
/** True iff `<dir>/.git` exists (file or directory). */
async function hasGitEntry(dir: string): Promise<boolean> {
try {
@@ -124,6 +168,9 @@ interface MakeProjectArgs {
readonly branch?: string
readonly dirty?: boolean
readonly lastActiveMs?: number
readonly ahead?: number
readonly behind?: number
readonly lastCommitMs?: number
}
function makeProject(args: MakeProjectArgs): ProjectInfo {
@@ -134,6 +181,9 @@ function makeProject(args: MakeProjectArgs): ProjectInfo {
branch: args.branch,
dirty: args.dirty,
lastActiveMs: args.lastActiveMs,
ahead: args.ahead,
behind: args.behind,
lastCommitMs: args.lastCommitMs,
sessions: [],
}
}
@@ -275,8 +325,11 @@ async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
const branch = await readBranch(repoPath)
// W3(a): the sync chip (ahead/behind + last-commit) rides the same per-repo
// git budget as the dirty check — gated by projectDirtyCheck, best-effort.
const dirty = cfg.projectDirtyCheck ? await readDirty(repoPath) : undefined
return makeProject({ path: repoPath, isGit: true, branch, dirty })
const sync = cfg.projectDirtyCheck ? await readSync(repoPath) : {}
return makeProject({ path: repoPath, isGit: true, branch, dirty, ...sync })
})
const merged = await mergeHistory(repos)
return dropParentFolders(dedupByPath(merged))

View File

@@ -0,0 +1,57 @@
/**
* src/http/session-groups.ts (W5 fan-out board) — cluster running sessions by
* the repo they belong to, for the fan-out discovery endpoint.
*
* PURE + STRING-ONLY (no `git` exec, no filesystem I/O) — fully node-unit-testable.
* Fan-out worktrees always live under `<repo>-worktrees/` (createWorktree's base,
* worktrees.ts computeWorktreeDir), so a session whose cwd is
* `<repo>-worktrees/<lane>` shares a group with the repo and its sibling lanes.
* A session that is NOT under a `*-worktrees` parent groups under its own cwd.
*/
import path from 'node:path'
import type { LiveSessionInfo, SessionGroup } from '../types.js'
/** The suffix createWorktree appends to derive the worktree base dir name. */
const WORKTREES_SUFFIX = '-worktrees'
/**
* Derive the repo root for a session cwd. If the cwd's PARENT directory is a
* `<name>-worktrees` folder, the repo root is `<dirname-of-parent>/<name>` (strip
* the `-worktrees` suffix). Otherwise the cwd is its own repo root. `null` in →
* `null` out (a session with no known cwd has no repo to group under). Pure.
*/
export function deriveRepoRoot(cwd: string | null): string | null {
if (cwd === null || cwd === '') return null
const parent = path.dirname(cwd)
const parentName = path.basename(parent)
if (parentName.length > WORKTREES_SUFFIX.length && parentName.endsWith(WORKTREES_SUFFIX)) {
const repoName = parentName.slice(0, -WORKTREES_SUFFIX.length)
return path.join(path.dirname(parent), repoName)
}
return cwd
}
/**
* Group live sessions by derived repo root. Sessions whose cwd resolves to the
* same repo (a repo + its `*-worktrees/*` lanes) land in one SessionGroup.
* Sessions with `cwd === null` are SKIPPED (no repo to attribute them to).
* Group order and per-group member order both follow the input order (which
* manager.list() already yields newest-first). Never mutates the input. Pure.
*/
export function groupSessionsByRepo(sessions: readonly LiveSessionInfo[]): SessionGroup[] {
const byRoot = new Map<string, SessionGroup>()
const order: string[] = []
for (const session of sessions) {
const repoRoot = deriveRepoRoot(session.cwd)
if (repoRoot === null) continue // no cwd → not attributable to a repo
let group = byRoot.get(repoRoot)
if (group === undefined) {
group = { repoRoot, label: path.basename(repoRoot), sessions: [] }
byRoot.set(repoRoot, group)
order.push(repoRoot)
}
group.sessions.push(session)
}
return order.map((root) => byRoot.get(root)!)
}

View File

@@ -9,7 +9,12 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { CreateWorktreeResult, WorktreeInfo } from '../types.js'
import type {
CreateWorktreeResult,
PruneWorktreesResult,
RemoveWorktreeResult,
WorktreeInfo,
} from '../types.js'
const execFileAsync = promisify(execFile)
@@ -249,3 +254,137 @@ export async function createWorktree(
return classifyWorktreeError(err)
}
}
// ── W4: remove a worktree (validate → registered-check → contain → execFile) ────
export interface RemoveWorktreeOptions {
readonly force?: boolean // git's own --force (required for a dirty tree)
readonly timeoutMs: number // cfg.worktreeTimeoutMs
}
/**
* Map a `git worktree remove` failure to a structured result with a SAFE message
* (never raw git stderr, SEC-M10). A dirty tree (git demands --force) → 409; an
* already-gone / not-a-working-tree race → 404; anything else → 500.
*/
function classifyRemoveError(err: unknown): RemoveWorktreeResult {
const s = extractStderr(err).toLowerCase()
if (
s.includes('contains modified or untracked files') ||
s.includes('use --force') ||
s.includes('use `--force`') ||
s.includes('is dirty')
) {
return { ok: false, status: 409, error: 'Worktree has uncommitted changes — force required.' }
}
if (s.includes('not a working tree') || s.includes('is not a working tree')) {
return { ok: false, status: 404, error: 'That path is not a worktree of this repository.' }
}
return { ok: false, status: 500, error: 'Failed to remove the worktree.' }
}
/**
* Remove a git worktree — the destructive path. The security spine (never `rm`,
* always via git):
* 1. `isGitRepo(repoPath)` gate → 404.
* 2. non-empty string target → 400.
* 3. the target's REALPATH must equal the realpath of an entry git itself
* reports in `worktree list` (canonical compare, defeats symlink tricks);
* no match → 404. This registered-check IS the containment (M2-consistent):
* an arbitrary FS path (e.g. /etc) can never match, so it is never touched.
* 4. the matched entry may not be the MAIN worktree → 400 (never delete the repo).
* 5. a LOCKED entry → 409 (unlock in a terminal first; never auto -f -f).
* 6. run `git worktree remove [--force] -- <git's own canonical path>` via
* execFile (no shell, `--` terminates options), never the raw user string.
* Returns a structured RemoveWorktreeResult; never throws.
*/
export async function removeWorktree(
repoPath: string,
targetPath: string,
opts: RemoveWorktreeOptions,
): Promise<RemoveWorktreeResult> {
if (!(await isGitRepo(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
if (typeof targetPath !== 'string' || targetPath.length === 0) {
return { ok: false, status: 400, error: 'Worktree path is required.' }
}
// Canonicalise the requested path and every registered worktree, then match on
// realpath — a symlink alias resolves to the same canonical target (M2).
const realTarget = await resolveRealPath(targetPath)
const worktrees = await listWorktrees(repoPath)
let match: WorktreeInfo | undefined
for (const wt of worktrees) {
if ((await resolveRealPath(wt.path)) === realTarget) {
match = wt
break
}
}
if (match === undefined) {
return { ok: false, status: 404, error: 'That path is not a worktree of this repository.' }
}
if (match.isMain) {
return { ok: false, status: 400, error: 'Cannot remove the main worktree.' }
}
if (match.locked === true) {
return { ok: false, status: 409, error: 'This worktree is locked; unlock it in a terminal first.' }
}
try {
const args = ['worktree', 'remove', ...(opts.force === true ? ['--force'] : []), '--', match.path]
await execFileAsync('git', args, {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: WORKTREE_MAX_BUFFER,
})
return { ok: true, path: match.path }
} catch (err: unknown) {
return classifyRemoveError(err)
}
}
// ── W4: prune stale worktrees (folders that git can no longer find) ─────────────
export interface PruneWorktreesOptions {
readonly timeoutMs: number // cfg.worktreeTimeoutMs
}
/**
* Parse `git worktree prune -v` output into best-effort human labels. git emits
* one `Removing <name>: <reason>` line per reclaimed entry (on stdout and/or
* stderr depending on version) — capture the label before the ':'. Pure.
*/
function parsePruneOutput(text: string): string[] {
const out: string[] = []
for (const raw of text.split('\n')) {
const line = raw.trim()
const m = /^Removing\s+(.+?):/.exec(line)
if (m !== null && m[1] !== undefined) out.push(m[1])
}
return out
}
/**
* Prune worktrees whose working directories are gone. `isGitRepo` gate (404),
* then `git worktree prune -v` via execFile (no shell). Idempotent — a clean repo
* yields `{ ok:true, pruned:[] }`. Returns a structured result; never throws.
*/
export async function pruneWorktrees(
repoPath: string,
opts: PruneWorktreesOptions,
): Promise<PruneWorktreesResult> {
if (!(await isGitRepo(repoPath))) {
return { ok: false, status: 404, error: 'Not a git repository.' }
}
try {
const { stdout, stderr } = await execFileAsync('git', ['worktree', 'prune', '-v'], {
cwd: repoPath,
timeout: opts.timeoutMs,
maxBuffer: WORKTREE_MAX_BUFFER,
})
return { ok: true, pruned: parsePruneOutput(`${stdout}\n${stderr}`) }
} catch {
return { ok: false, status: 500, error: 'Failed to prune worktrees.' }
}
}

View File

@@ -24,23 +24,37 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { readFileSync } from 'node:fs'
import express from 'express'
import type { Response } from 'express'
import type { NextFunction, Request, Response } from 'express'
import { WebSocketServer } from 'ws'
import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http'
import { loadConfig } from './config.js'
import { parseClientMessage, serialize } from './protocol.js'
import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import {
buildSetCookie,
constantTimeEqual,
cookieIsAuthed,
isAuthEnabled,
isHttpsRequest,
} from './http/auth.js'
import { parseHookEvent } from './http/hook.js'
import { deriveApprovalPreview } from './http/approval-preview.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor } from './http/editor.js'
import { getDiff } from './http/diff.js'
import { openInEditor, openFileInEditor } from './http/editor.js'
import { getDiff, isPlausibleRev } from './http/diff.js'
import { getGitLog } from './http/git-log.js'
import { buildDigest, clampSince } from './http/digest.js'
import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree } from './http/worktrees.js'
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
import { groupSessionsByRepo } from './http/session-groups.js'
import { stageFiles, commit as gitCommit, push as gitPush } from './http/git-ops.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
@@ -49,6 +63,7 @@ import { createPushService } from './push/push-service.js'
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
import { initFcm, normalizeFcmToken } from './push/fcm.js'
import type {
ApprovalPreview,
Config,
NotifyService,
PermissionGate,
@@ -74,6 +89,19 @@ const LOG_FIELD_MAX = 200
const RATE_LIMIT_WINDOW_MS = 60_000
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP
const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound)
const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard)
/** Loopback-only Claude Code hook ingest paths the auth gate lets through from a
* loopback peer with no cookie (the token is about REMOTE access; hooks run on
* the host). Each handler independently re-checks isLoopback (defense in depth). */
const LOOPBACK_INGEST_PATHS: ReadonlySet<string> = new Set([
'/hook',
'/hook/permission',
'/hook/status',
])
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
@@ -217,6 +245,50 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
// the injected text lands once the prompt is ready. Cleared on shutdown; the
// fired callback re-checks idle + a stable lastOutputAt cursor before draining.
const drainTimers = new Map<string, ReturnType<typeof setTimeout>>()
/**
* W2: schedule (or reschedule) a single idle-drain for `sessionId`. Debounced
* per session — each Stop clears any pending timer and restarts the window, so
* flapping Stop events fire at most one drain. On fire we drain ONE entry only
* if the session is still idle, unexited, and its lastOutputAt has not moved
* since scheduling (no output mid-settle = Claude really finished, not
* mid-render). One entry per idle → natural pacing (the injected prompt makes
* Claude work again, and its next Stop drains the next entry).
*/
function scheduleDrain(sessionId: string): void {
if (!cfg.queueEnabled) return
const session = manager.get(sessionId)
if (session === undefined || session.exitedAt !== null) return
if (session.queue.length === 0) return
const existing = drainTimers.get(sessionId)
if (existing !== undefined) clearTimeout(existing)
const outputCursor = session.lastOutputAt
const timer = setTimeout(() => {
drainTimers.delete(sessionId)
const s = manager.get(sessionId)
if (s === undefined || s.exitedAt !== null) return
// Settle guard: new output since scheduling → Claude is still active; skip
// (a later genuine Stop reschedules). Also require the idle status to hold.
if (s.lastOutputAt !== outputCursor) return
if (s.claudeStatus !== 'idle') return
manager.drainOne(sessionId)
}, cfg.queueSettleMs)
// Don't let a pending drain keep the process alive; a stale fire is harmless.
timer.unref()
drainTimers.set(sessionId, timer)
}
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
@@ -228,6 +300,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
token: string
expiresAt: number
gate: PermissionGate
/** W1: bounded command/diff preview of the held tool, re-sent to late joiners
* exactly like `gate`. Undefined for non-previewable tools (e.g. ExitPlanMode). */
preview?: ApprovalPreview
}
const pendingApprovals = new Map<string, PendingApproval>()
@@ -257,9 +332,134 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
next()
})
const publicDir = path.join(__dirname, '..', 'public')
// ── w5-access-token: optional shared-token gate (ADDITIVE — in front of the
// Origin/CSRF model, never replacing it). Unset WEBTERM_TOKEN ⇒ DISABLED ⇒
// every request falls straight through `next()` (LAN zero-config unchanged).
//
// HONEST BOUNDARY: a bar-raiser, NOT a TLS/Tailscale substitute. On bare ws://
// the cookie/token travel in cleartext and are replayable by a LAN sniffer;
// this only meaningfully hardens the relay/tunnel (TLS-terminated) path. See
// src/http/auth.ts and TECH_DOC §7 ("never port-forward this raw").
// Startup-cached login page (served by GET /login BEFORE the gate, so it is
// always reachable while unauthed). Fallback keeps the server alive if the
// asset is missing rather than crashing at boot.
const loginHtml = ((): string => {
try {
return readFileSync(path.join(publicDir, 'login.html'), 'utf8')
} catch {
return '<!doctype html><meta charset="utf-8"><title>Sign in</title><form method="POST" action="/auth"><input type="password" name="token" autofocus><button>Unlock</button></form>'
}
})()
/** True for a top-level browser navigation (wants HTML) vs an XHR/fetch (wants JSON). */
function acceptsHtml(req: Request): boolean {
const accept = req.headers['accept']
return typeof accept === 'string' && accept.includes('text/html')
}
/** Redirect target for the `?token=` bootstrap: same path with `token` stripped
* so no secret is left in browser history / Referer. */
function pathWithoutToken(req: Request): string {
const u = new URL(req.originalUrl, 'http://localhost')
u.searchParams.delete('token')
const qs = u.searchParams.toString()
return u.pathname + (qs ? `?${qs}` : '')
}
// GET /login — always reachable. On ?e=1 reveal the error banner (no JS: the
// CSP is `script-src 'self'`, so the toggle is a server-side class swap).
app.get('/login', (req, res) => {
const html =
req.query['e'] === '1'
? loginHtml.replace('ERRSTATE', 'show-error')
: loginHtml.replace('ERRSTATE', '')
res.type('html').send(html)
})
// POST /auth — always reachable, rate-limited. Accepts urlencoded (native form)
// AND json (XHR). Valid ⇒ Set-Cookie + 302→/ (form) or 204 (XHR). Invalid ⇒
// 302→/login?e=1 (form) or 401 (XHR). Over the limit ⇒ 429.
app.post(
'/auth',
express.urlencoded({ extended: false, limit: '1kb' }),
express.json({ limit: '1kb' }),
(req, res) => {
const ip = req.socket.remoteAddress ?? 'unknown'
if (!authLimiter(ip, Date.now())) {
res.status(429).end()
return
}
// Disabled ⇒ nothing to authenticate; acknowledge without setting a cookie.
if (!isAuthEnabled(cfg)) {
res.status(204).end()
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const candidate = typeof body['token'] === 'string' ? body['token'] : ''
const isForm = acceptsHtml(req)
if (constantTimeEqual(candidate, cfg.webtermToken)) {
res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
if (isForm) res.redirect(302, '/')
else res.status(204).end()
return
}
if (isForm) res.redirect(302, '/login?e=1')
else res.status(401).json({ error: 'invalid token' })
},
)
// The global auth gate. Runs after the security-headers middleware and BEFORE
// express.static + every API route below, so ONE central policy point covers
// the whole app (the origin.ts idiom). Allow-list, in order:
function authGate(req: Request, res: Response, next: NextFunction): void {
// 1. Disabled → everything open (zero-config LAN unchanged).
if (!isAuthEnabled(cfg)) {
next()
return
}
// 2. Loopback-only Claude Code hook ingest (POST /hook, /hook/permission,
// /hook/status) has no cookie and must keep working — the token is about
// REMOTE access, not the host's own hook side-channel. DEVIATION from the
// plan's blanket `isLoopback → next()`: scoped to these three ingest paths
// so the gate still protects every OTHER route even from a loopback peer
// (a token-enabled deploy then also gates the local browser — strictly
// more secure, and testable from a 127.0.0.1 harness).
if (LOOPBACK_INGEST_PATHS.has(req.path) && isLoopback(req.socket.remoteAddress ?? '')) {
next()
return
}
// 3. GET with ?token= bootstrap link → validate (rate-limited), set cookie +
// redirect to the same path with the token stripped, else → /login.
if (req.method === 'GET' && typeof req.query['token'] === 'string' && req.query['token'] !== '') {
const ip = req.socket.remoteAddress ?? 'unknown'
if (!authLimiter(ip, Date.now())) {
res.status(429).end()
return
}
if (constantTimeEqual(req.query['token'], cfg.webtermToken)) {
res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
res.redirect(302, pathWithoutToken(req))
} else {
res.redirect(302, '/login')
}
return
}
// 4. Valid cookie → allow.
if (cookieIsAuthed(cfg, req.headers['cookie'])) {
next()
return
}
// 5. Unauthed: HTML navigation → login page; XHR/other → 401 JSON.
if (acceptsHtml(req)) res.redirect(302, '/login')
else res.status(401).json({ error: 'authentication required' })
}
app.use(authGate)
// Serve the entire public/ directory (including public/build/ esbuild output).
// Note: `npm run build:web` must be run before `npm start` to populate public/build/.
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code history (O2) — list past sessions for the resume browser ──
@@ -278,6 +478,23 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.json(manager.list())
})
// ── W5 fan-out board: running sessions clustered by repo (read-only) ──────
// Pure string grouping over manager.list() (no git/FS work); same threat model
// as /live-sessions and /digest → no Origin guard. Registered BEFORE the
// /live-sessions/:id routes so "grouped" is never captured as an :id.
app.get('/live-sessions/grouped', (_req, res) => {
res.json(groupSessionsByRepo(manager.list()))
})
// ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
// A pure read-side aggregate over manager.list() + in-memory telemetry/status;
// no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
// the client's last-seen watermark (bad/absent → 0 = "everything is new").
app.get('/digest', (req, res) => {
const since = clampSince(req.query['since'])
res.json(buildDigest(manager.list(), since))
})
// Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
app.get('/projects', async (_req, res) => {
try {
@@ -345,6 +562,70 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
})
})
// ── W2 inject queue: enqueue a follow-up prompt fired on next idle ────────
// State-changing (causes shell input on idle) → Origin guard (CSRF) + per-IP
// rate limit. Body: { text: string, appendEnter?: boolean }. Bytes are stored
// and later injected VERBATIM (byte-shuttle) — bounded in size and count, never
// parsed as a shell command. `text` + optional trailing \r is capped by
// queueItemMaxBytes; the queue depth is capped by queueMaxItems.
app.post('/live-sessions/:id/queue', express.json({ limit: '16kb' }), (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.queueEnabled) {
res.status(503).json({ error: 'queue disabled' })
return
}
if (!queueLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const id = req.params.id
if (!SESSION_ID_RE.test(id)) {
res.status(400).json({ error: 'invalid session id' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const rawText = body['text']
if (typeof rawText !== 'string' || rawText.length === 0) {
res.status(400).json({ error: 'text must be a non-empty string' })
return
}
// FE decides Enter (mirrors quick-reply's appendEnter): materialize the exact
// bytes to inject here so the stored entry is byte-identical to a keystroke.
const text = body['appendEnter'] === true ? rawText + '\r' : rawText
if (Buffer.byteLength(text, 'utf8') > cfg.queueItemMaxBytes) {
res.status(413).json({ error: 'text too large' })
return
}
const result = manager.enqueueFollowup(id, text)
if (!result.ok) {
// full → 409 (never silently drop); unknown/exited → 404.
res.status(result.reason === 'full' ? 409 : 404).json({ error: result.reason })
return
}
res.json({ length: result.length })
})
// Read-only queue view (depth + the user's own queued text). No Origin guard —
// same threat model as GET /live-sessions.
app.get('/live-sessions/:id/queue', (req, res) => {
const session = manager.get(req.params.id)
if (session === undefined) {
res.status(404).end()
return
}
res.json({ length: session.queue.length, items: [...session.queue] })
})
// Cancel all pending entries (escape hatch). State-changing → Origin guard.
app.delete('/live-sessions/:id/queue', (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!manager.clearQueue(req.params.id)) {
res.status(404).end()
return
}
res.json({ length: 0 })
})
// CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
// upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
// page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
@@ -380,11 +661,18 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Open a project in the host's desktop editor (v0.6 Projects panel — VS Code
// logo). State-changing (spawns a GUI process), so it carries the same Origin
// guard as the DELETE routes. The path is validated + passed via execFile (no
// shell) in openInEditor.
// shell) in openInEditor / openFileInEditor.
//
// W1: two modes on one route. `file` present ⇒ open that FILE at `line` (a
// clicked terminal path like `src/app.ts:42`); else the original directory mode
// (`path`). Discriminated by body shape so the Projects panel is unchanged.
app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
const body = (req.body ?? {}) as Record<string, unknown>
const result = await openInEditor(cfg, body['path'])
const result =
body['file'] !== undefined
? await openFileInEditor(cfg, body['file'], body['line'])
: await openInEditor(cfg, body['path'])
if (result.ok) {
res.status(204).end()
return
@@ -414,6 +702,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
const session = manager.get(ev.sessionId)
if (session !== undefined) void pushService.notify(session, 'done')
// W2: Claude just went idle — schedule a debounced drain of the next queued
// follow-up (fires after the settle delay iff still idle + output stable).
scheduleDrain(ev.sessionId)
}
res.status(204).end()
})
@@ -453,6 +744,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'
// W1: derive a bounded, sanitized command/diff preview from the untrusted
// tool_input so the approve/reject bar shows WHAT will run (never blind).
// Non-previewable tools / malformed input → undefined (name-only bar).
const preview = deriveApprovalPreview(tool, body['tool_input']) ?? undefined
resolvePending(sessionId, {}) // clear any stale hold for this session
const token = randomUUID()
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
@@ -461,14 +757,21 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
pendingApprovals.set(sessionId, {
res,
timer,
token,
expiresAt,
gate,
...(preview !== undefined ? { preview } : {}),
})
// A1: push the lock-screen approval (carrying the capability token) to every
// subscribed device. Best-effort — failures are logged inside push-service.
void pushService.notify(session, 'needs-input', token)
// Show the approve/reject affordance on every attached client.
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)
// Show the approve/reject affordance (+ W1 preview) on every attached client.
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate, undefined, undefined, preview)
})
// ── A1 push subscription + lock-screen decision routes ────────────────────
@@ -668,15 +971,74 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
// FR-B1.9: optional ?base=<rev> — diff HEAD against a base commit-ish. The
// rev-parse allow-list (in getDiff) is the real defense; isPlausibleRev
// rejects flag-injection/junk fast with a 400 before any git call.
const rawBase = req.query['base']
const base = typeof rawBase === 'string' && rawBase !== '' ? rawBase : undefined
if (base !== undefined && !isPlausibleRev(base)) {
res.status(400).json({ error: 'invalid base revision' })
return
}
try {
const staged = req.query['staged'] === '1'
res.json(await getDiff(target, { staged, cfg }))
res.json(await getDiff(target, { staged, base, cfg }))
} catch (err) {
console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read diff' })
}
})
// ── W3 quick-wins (d): read-only recent-commit log (no Origin guard) ──────
// Same three-prong path validation (isValidGitDir, SEC-H7) as /projects/diff.
// `?n=<int>` is clamped to [1, GIT_LOG_MAX] inside getGitLog; `path` missing →
// 400, non-git dir → 404, git failure → best-effort empty (getGitLog) → 200.
app.get('/projects/log', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
if (!(await isValidGitDir(target))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
const rawN = req.query['n']
const n = typeof rawN === 'string' ? Number.parseInt(rawN, 10) : undefined
try {
res.json(await getGitLog(target, { n, timeoutMs: cfg.diffTimeoutMs }))
} catch (err) {
console.error('[server] /projects/log failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read git log' })
}
})
// ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
// Out-of-band side-channel: spawns the host's `gh` CLI to read the current
// branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
// a NETWORK call to GitHub using the host's own gh/GH_TOKEN credential — this
// route NEVER accepts or forwards a token, only triggers gh's own auth, and
// GH_ENABLED=0 disables it entirely. Always 200 on a valid git dir: every
// degrade (gh missing / unauthed / no PR / disabled) lives in the response body
// (PrStatus.availability), not the HTTP status, so the FE renders one chip.
app.get('/projects/pr', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
if (!(await isValidGitDir(target))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
try {
res.json(await getPrStatus(target, cfg))
} catch (err) {
console.error('[server] /projects/pr failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read PR status' })
}
})
// ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
@@ -724,9 +1086,178 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' })
})
// ── W4 remove a git worktree (destructive → Origin guard, same as create) ────
app.delete('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.worktreeEnabled) {
res.status(403).json({ error: 'Worktree management is disabled.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const worktreePath = typeof body['worktreePath'] === 'string' ? body['worktreePath'] : undefined
const force = body['force'] === true
if (repoPath === undefined || worktreePath === undefined) {
res.status(400).json({ error: 'path and worktreePath are required' })
return
}
// Audit (destructive): who/what, sanitized + truncated (never raw control chars).
console.error(
`[server] worktree remove: path=${sanitizeForLog(repoPath)} worktree=${sanitizeForLog(worktreePath)} force=${force}`,
)
const result = await removeWorktree(repoPath, worktreePath, {
force,
timeoutMs: cfg.worktreeTimeoutMs,
})
if (result.ok) {
res.status(200).json({ ok: true, path: result.path })
return
}
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to remove the worktree.' })
})
// ── W4 prune stale worktrees (destructive → Origin guard) ────────────────────
app.post('/projects/worktree/prune', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.worktreeEnabled) {
res.status(403).json({ error: 'Worktree management is disabled.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
console.error(`[server] worktree prune: path=${sanitizeForLog(repoPath)}`)
const result = await pruneWorktrees(repoPath, { timeoutMs: cfg.worktreeTimeoutMs })
if (result.ok) {
res.status(200).json({ ok: true, pruned: result.pruned ?? [] })
return
}
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' })
})
// ── W4 git write: stage / commit / push (the HIGHEST-risk channel) ───────────
// Every route: requireAllowedOrigin (CSRF — this is a WRITE channel, unlike the
// read-only /projects/diff) → gitOpsEnabled kill-switch (403) → per-IP rate
// limit (429) → isValidGitDir three-prong (404). The delegates in git-ops.ts
// re-validate and realpath-CONTAIN every untrusted path/message (defense in
// depth) and return SAFE, classified errors only (never raw git stderr).
// Stage / unstage specific files. `stage` (default true) → git add; false →
// git restore --staged. `files[]` is capped + realpath-contained in git-ops.
app.post('/projects/git/stage', express.json({ limit: '64kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const files = Array.isArray(body['files']) ? (body['files'] as unknown[]) : undefined
const stage = body['stage'] === undefined ? true : body['stage'] === true
if (repoPath === undefined || files === undefined) {
res.status(400).json({ error: 'path and files are required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
const result = await stageFiles(repoPath, files, stage, {
timeoutMs: cfg.gitOpsTimeoutMs,
maxFiles: cfg.diffMaxFiles,
})
if (result.ok) {
res.status(200).json({ ok: true, staged: result.staged, count: result.count })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// Commit the STAGED changes. Message is length-capped + non-empty checked and
// passed as a single `-m <msg>` argv (never a shell string, never a pathspec).
app.post('/projects/git/commit', express.json({ limit: '16kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const message = typeof body['message'] === 'string' ? body['message'] : ''
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
// Audit (write): who/what, sanitized + truncated (never raw control chars).
console.error(`[server] git commit: path=${sanitizeForLog(repoPath)}`)
const result = await gitCommit(repoPath, message, {
timeoutMs: cfg.gitOpsTimeoutMs,
maxLen: cfg.commitMsgMaxLen,
})
if (result.ok) {
res.status(200).json({ ok: true, commit: result.commit })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// Push the CURRENT branch to its existing upstream, or `-u <sole-remote>
// <branch>` if none. Remote/branch are derived server-side; never a force-push.
// Tighter per-IP rate limit than stage/commit (network-bound).
app.post('/projects/git/push', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.gitOpsEnabled) {
res.status(403).json({ error: 'Git operations are disabled.' })
return
}
if (!gitPushLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).json({ error: 'Too many requests.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
if (repoPath === undefined) {
res.status(400).json({ error: 'path is required' })
return
}
if (!(await isValidGitDir(repoPath))) {
res.status(404).json({ error: 'project not found' })
return
}
console.error(`[server] git push: path=${sanitizeForLog(repoPath)}`)
const result = await gitPush(repoPath, { timeoutMs: cfg.gitPushTimeoutMs })
if (result.ok) {
res.status(200).json({ ok: true, branch: result.branch, remote: result.remote })
return
}
res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode }
const uiConfig: UiConfig = {
allowAutoMode: cfg.allowAutoMode,
// W3(b): expose the cost budget only when set (>0) so the FE can derive
// cost-overage warn styling; a non-secret number, safe over /config/ui.
...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
// W5 fan-out board: let the FE stepper max mirror MAX_FANOUT_LANES.
maxFanoutLanes: cfg.maxFanoutLanes,
}
res.json(uiConfig)
})
@@ -769,6 +1300,16 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
return
}
// 2b. Access-token gate (w5-access-token) — ADDITIVE, runs AFTER the Origin
// check (never weakens it) and BEFORE the handshake. The browser
// auto-sends the HttpOnly cookie on the same-origin WS upgrade, so no
// frontend change is needed. Disabled (no token) → this is a no-op.
if (isAuthEnabled(cfg) && !cookieIsAuthed(cfg, req.headers['cookie'])) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
// 3. Hand off to wss — it completes the handshake and emits 'connection'.
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
@@ -857,9 +1398,18 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// portion lives here because the server owns pendingApprovals).
const heldApproval = pendingApprovals.get(boundSessionId)
if (heldApproval !== undefined) {
// W1: re-send the bounded preview alongside gate so a late-joining
// device sees WHAT is held, not just that something is. JSON.stringify
// drops `preview` when undefined (non-previewable tools).
safeSend(
ws,
serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }),
serialize({
type: 'status',
status: 'waiting',
pending: true,
gate: heldApproval.gate,
...(heldApproval.preview !== undefined ? { preview: heldApproval.preview } : {}),
}),
)
}
return
@@ -929,6 +1479,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// ── Graceful shutdown helper ──────────────────────────────────────────────
function doShutdown(): void {
clearInterval(reapTimer)
// W2: cancel any pending idle-drain timers so they can't fire post-shutdown.
for (const t of drainTimers.values()) clearTimeout(t)
drainTimers.clear()
manager.shutdown()
}

View File

@@ -26,9 +26,11 @@
*/
import type {
ApprovalPreview,
ClaudeStatus,
Config,
Dims,
EnqueueResult,
LiveSessionInfo,
NotifyService,
PermissionGate,
@@ -40,7 +42,7 @@ import type {
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, broadcast, kill } from './session.js';
import { createSession, attachWs, broadcast, kill, writeInput } from './session.js';
import { appendEvent, makeTimelineEvent } from './timeline.js';
import { hasSession, tmuxName } from './tmux.js';
@@ -193,6 +195,7 @@ export function createSessionManager(
rows: s.pty.rows,
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
lastOutputAt: s.lastOutputAt, // T-iOS-37: unread watermark (M3 record)
queueLength: s.queue.length, // W2: pending inject-queue depth
}))
.sort((a, b) => b.createdAt - a.createdAt);
}
@@ -218,7 +221,9 @@ export function createSessionManager(
* H2/H3: a Claude Code hook reported activity for `sessionId`. Update the
* status, append a timeline event (A4) when `eventClass` is supplied and the
* timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4)
* when the held approval is a 'tool'/'plan' gate. No-op for an unknown session.
* when the held approval is a 'tool'/'plan' gate, and `preview` (W1) when a
* bounded command/diff preview of the held tool was derived. No-op for an
* unknown session.
*/
function handleHookEvent(
sessionId: string,
@@ -228,6 +233,7 @@ export function createSessionManager(
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview,
): void {
const session = sessions.get(sessionId);
if (session === undefined) return;
@@ -246,6 +252,7 @@ export function createSessionManager(
if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true;
if (gate !== undefined) msg.gate = gate;
if (preview !== undefined) msg.preview = preview;
broadcast(session, msg);
}
@@ -258,6 +265,29 @@ export function createSessionManager(
if (session === undefined) return;
session.telemetry = telemetry;
broadcast(session, { type: 'telemetry', telemetry });
maybeAlertBudget(session, telemetry);
}
/**
* W3 quick-wins (b): fire a one-shot cost-budget alert when a session's cost
* first crosses COST_BUDGET_USD. Mirrors the A5 stuck-latch shape but is NEVER
* re-armed (cost is monotonic) — so it fires at most once per session. Disabled
* when the budget is 0/unset or the frame carries no cost.
*
* No new ServerMessage variant: the "warning broadcast" is the telemetry frame
* already sent above (clients derive the warn from costUsd >= costBudgetUsd via
* GET /config/ui). The one distinct new action on crossing is a 'budget' push.
*/
function maybeAlertBudget(session: Session, telemetry: StatusTelemetry): void {
if (cfg.costBudgetUsd <= 0) return; // disabled
if (session.budgetNotified) return; // already alerted (latch)
const cost = telemetry.costUsd;
if (cost === undefined || cost < cfg.costBudgetUsd) return; // no crossing
session.budgetNotified = true;
void notifyService?.notify(session, 'budget').catch((err: unknown) => {
console.error('[manager] budget notification failed', err);
});
}
/**
@@ -318,6 +348,57 @@ export function createSessionManager(
return count;
}
/**
* W2: append a verbatim byte string to a session's inject queue.
*
* The queue is a bounded FIFO (cfg.queueMaxItems). The HTTP route validates and
* size-caps `text` before calling; here we only enforce depth + existence.
* Immutable update: replace the frozen array wholesale, never mutate in place.
* Broadcasts the new depth so every mirrored device updates its badge.
*/
function enqueueFollowup(id: string, text: string): EnqueueResult {
const session = sessions.get(id);
if (session === undefined) return { ok: false, reason: 'unknown' };
if (session.exitedAt !== null) return { ok: false, reason: 'exited' };
if (session.queue.length >= cfg.queueMaxItems) return { ok: false, reason: 'full' };
session.queue = Object.freeze([...session.queue, text]);
const length = session.queue.length;
broadcast(session, { type: 'queue', length });
return { ok: true, length };
}
/**
* W2: pop the head entry and write it to the PTY (byte-identical to a keystroke;
* writeInput broadcasts the resulting output to all mirrors). Returns the
* injected string, or null when there is nothing to drain / the session is
* unknown or already exited (double-guards L4 — writeInput is itself a no-op
* after exit). Broadcasts the new depth.
*/
function drainOne(id: string): string | null {
const session = sessions.get(id);
if (session === undefined || session.exitedAt !== null) return null;
if (session.queue.length === 0) return null;
const head = session.queue[0] as string;
session.queue = Object.freeze(session.queue.slice(1));
writeInput(session, head);
broadcast(session, { type: 'queue', length: session.queue.length });
return head;
}
/**
* W2: clear all pending entries (cancel-all escape hatch). Broadcasts depth 0.
* Returns false for an unknown session.
*/
function clearQueue(id: string): boolean {
const session = sessions.get(id);
if (session === undefined) return false;
session.queue = Object.freeze([] as string[]);
broadcast(session, { type: 'queue', length: 0 });
return true;
}
/**
* Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For
* tmux sessions (H1), kill only the client pty — the tmux server keeps the
@@ -343,6 +424,9 @@ export function createSessionManager(
handleStatusLine,
sweepStuck,
reapIdle,
enqueueFollowup,
drainOne,
clearQueue,
shutdown,
};
}

View File

@@ -136,7 +136,10 @@ export function createSession(
// v0.7 Walk-away Workbench fields (T-spawn-env):
timeline: Object.freeze([] as TimelineEvent[]),
stuckNotified: false, // A5: re-armed to false by each pty output
budgetNotified: false, // W3(b): cost-budget one-shot latch, never re-armed
telemetry: null, // B2: updated by manager.handleStatusLine
// W2: inject follow-up queue — empty at spawn; replaced wholesale by manager.
queue: Object.freeze([] as string[]),
};
// onData: persist to scrollback, refresh liveness, broadcast to all clients.

View File

@@ -34,6 +34,11 @@ export interface Config {
readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
// w5-access-token — optional shared access token gate (ADDITIVE, in front of the
// Origin/CSRF model; never replaces it). undefined/empty ⇒ auth DISABLED, so LAN
// zero-config is preserved. When set: validated at load (16512 URL/cookie-safe
// chars). SECRET — never log; never return over /config/ui. See src/http/auth.ts.
readonly webtermToken: string | undefined; // WEBTERM_TOKEN
// v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3)
readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir]
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
@@ -61,15 +66,32 @@ export interface Config {
readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000
readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB
readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300
// W3 PR + CI status chip (gh)
readonly ghEnabled: boolean; // GH_ENABLED, default true (false → never spawns gh)
readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
// B2 statusLine telemetry
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
// W3 quick-wins (b) cost budget guard
readonly costBudgetUsd: number; // COST_BUDGET_USD, default 0 (0/unset = disabled)
// B3 git worktree creation
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
// W5 fan-out board — cap on lanes one task may be fanned across (grid-6 capacity)
readonly maxFanoutLanes: number; // MAX_FANOUT_LANES, default 6
// W4 git write (stage / commit / push) — the highest-risk write channel
readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
readonly gitPushTimeoutMs: number; // GIT_PUSH_TIMEOUT_MS, default 120000 (network-bound push)
readonly commitMsgMaxLen: number; // COMMIT_MSG_MAX_LEN, default 5000 (message length cap)
// B4 permission-mode relay
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
// W2 server-side PTY-inject follow-up queue
readonly queueEnabled: boolean; // QUEUE_ENABLED, default true (routes 503 when off)
readonly queueMaxItems: number; // QUEUE_MAX_ITEMS, default 10 (bounded depth, DoS guard)
readonly queueItemMaxBytes: number; // QUEUE_ITEM_MAX_BYTES, default 4096 (bounded size)
readonly queueSettleMs: number; // QUEUE_SETTLE_MS, default 1500 (drain-after-idle delay)
}
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
@@ -100,6 +122,21 @@ export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';
* gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
export type PermissionGate = 'tool' | 'plan';
/** W1: a compact, BOUNDED preview of what a held tool approval would run, so a
* remote one-tap approval is no longer blind. Derived server-side from the hook
* `tool_input` (attacker-influenced) — sanitized (control/ANSI chars stripped),
* line-capped and byte-capped in src/http/approval-preview.ts. Discriminated on
* `kind`:
* - 'command' → a shell command string (Bash). Newlines are PRESERVED; every
* other control/ANSI char is stripped. The FE renders it in a <pre> via
* textContent only (never innerHTML).
* - 'diff' → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
* rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
* `truncated` = the source exceeded the line/byte cap and was clipped. */
export type ApprovalPreview =
| { kind: 'command'; text: string; truncated?: boolean }
| { kind: 'diff'; file: DiffFile; truncated?: boolean };
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
@@ -116,8 +153,16 @@ export type ServerMessage =
detail?: string;
pending?: boolean;
gate?: PermissionGate;
/** W1: bounded preview of the held tool's command/diff; present only on a
* held (pending) waiting status for a Bash/Edit-family tool. Older clients
* ignore it (additive + optional). */
preview?: ApprovalPreview;
}
| { type: 'telemetry'; telemetry: StatusTelemetry };
| { type: 'telemetry'; telemetry: StatusTelemetry }
/** W2: the current pending-inject queue depth for this session. Broadcast to
* every attached device so all mirrors show the same "N queued" badge. Older
* clients ignore it (additive variant). */
| { type: 'queue'; length: number };
/** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult =
@@ -226,8 +271,17 @@ export interface Session {
/** A5: true once a stuck alert fired this round; re-armed (→false) by the next
* pty.onData so each silent round alerts at most once. */
stuckNotified: boolean;
/** W3 quick-wins (b): true once the cost-budget alert fired for this session.
* One-shot latch — NEVER re-armed (cost is monotonic), so the budget push +
* warning broadcast happen at most once per session. */
budgetNotified: boolean;
/** B2: latest statusLine telemetry for this session; null until first report. */
telemetry: StatusTelemetry | null;
/** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes
* idle. Head fires first. Mutable runtime handle on the immutable meta (like
* timeline): replaced wholesale (Object.freeze of a new array), never mutated
* in place. Capped by cfg.queueMaxItems; each entry capped by queueItemMaxBytes. */
queue: readonly string[];
readonly pty: IPty;
}
@@ -259,6 +313,30 @@ export interface LiveSessionInfo {
* (lastOutputAt > local last-seen). Additive OPTIONAL: older consumers
* that build or read LiveSessionInfo without it stay valid. */
readonly lastOutputAt?: number;
/** W2: number of pending inject-queue entries (0 when empty). Additive OPTIONAL
* so the manage grid and /live-sessions can surface queue depth per session. */
readonly queueLength?: number;
}
/* ───────────────── W5 fan-out board (parallel agent lanes) ───────────────── */
/** A group of running sessions sharing a repo/worktree-root (fan-out discovery).
* Derived STRING-ONLY from each session's cwd (no git exec): fan-out worktrees
* live under `<repo>-worktrees/`, so sessions whose cwd shares that parent
* cluster into one group. impl: src/http/session-groups.ts groupSessionsByRepo. */
export interface SessionGroup {
repoRoot: string; // derived repo dir (parent of the *-worktrees folder, or the cwd)
label: string; // basename(repoRoot)
sessions: LiveSessionInfo[]; // members, newest-first (already the manager.list order)
}
/** Launch options the FE passes to TabApp.launchFanout (FE-internal, but typed
* shared so the form and the launcher agree on ONE shape). */
export interface FanoutLaunchOpts {
prompt: string;
lanes: number; // 2..maxFanoutLanes (clamped to grid-6 capacity + the DoS cap)
branchBase: string; // slug; lanes get `${branchBase}-lane-${i}`
mode?: PermissionMode; // reuse PermissionMode
}
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
@@ -283,6 +361,10 @@ export interface ProjectInfo {
branch?: string; // current branch (git repos only)
dirty?: boolean; // uncommitted changes (when projectDirtyCheck)
lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key
// W3 quick-wins (a) sync chip — best-effort git ahead/behind vs @{u} + last commit.
ahead?: number; // commits on HEAD not on @{u} (git rev-list, right count)
behind?: number; // commits on @{u} not on HEAD (git rev-list, left count)
lastCommitMs?: number; // git log -1 --format=%ct * 1000 (HEAD commit time)
sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty)
}
@@ -311,6 +393,13 @@ export interface ProjectDetail {
claudeMd?: string; // its content (truncated for display) when present
}
/** W2: outcome of SessionManager.enqueueFollowup. Success carries the new depth;
* failure names the reason so the HTTP route can pick the right status code
* (unknown/exited → 404, full → 409). */
export type EnqueueResult =
| { ok: true; length: number }
| { ok: false; reason: 'unknown' | 'full' | 'exited' };
export interface SessionManager {
handleAttach(
ws: WebSocketLike,
@@ -336,6 +425,7 @@ export interface SessionManager {
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview,
): void;
/** B2: store the latest statusLine telemetry for a session and broadcast a
* `telemetry` message to all attached clients. */
@@ -346,6 +436,18 @@ export interface SessionManager {
sweepStuck(now: number): void;
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
reapIdle(now: number): number;
/** W2: append verbatim `text` to a session's inject queue (bounded by
* queueMaxItems); broadcasts the new depth. The route validates/sizes `text`
* before calling. Never spawns; only mutates the queue handle. */
enqueueFollowup(id: string, text: string): EnqueueResult;
/** W2: pop the head entry and write it to the PTY (byte-identical to a
* keystroke, broadcast to all mirrors); broadcasts the new depth. Returns the
* injected string, or null when the queue is empty / session is unknown or
* exited (double-guards L4). */
drainOne(id: string): string | null;
/** W2: clear all pending entries (cancel-all escape hatch); broadcasts depth 0.
* Returns false for an unknown session. */
clearQueue(id: string): boolean;
shutdown(): void;
}
@@ -372,8 +474,9 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto';
/* ── A1 push notifications (§3.3, §A1) ── */
/** The three proactive signals pushed to the phone (§3.3 / §A1). */
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
/** The proactive signals pushed to the phone (§3.3 / §A1). 'budget' (W3
* quick-wins b) fires once when a session's cost crosses COST_BUDGET_USD. */
export type NotifyClass = 'needs-input' | 'done' | 'stuck' | 'budget';
/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
* (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
@@ -476,6 +579,40 @@ export interface DiffResult {
files: DiffFile[];
staged: boolean;
truncated: boolean;
base?: string; // echoed when the diff was against a base revision (?base=<rev>)
}
/* ── W3 PR + CI status chip (gh) ── */
/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
export type PrAvailability =
| 'ok' // a PR exists for the current branch; fields below are populated
| 'no-pr' // gh works but the branch has no PR (or no remote/default repo)
| 'not-installed' // `gh` binary not found on PATH (ENOENT)
| 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
| 'disabled' // GH_ENABLED=0 — feature off, never spawns gh
| 'error'; // gh spawned but failed for another reason (timeout, etc.)
/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
export interface PrCheckSummary {
total: number;
passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
}
/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
export interface PrStatus {
availability: PrAvailability;
number?: number;
title?: string;
url?: string;
state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
isDraft?: boolean;
mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
headRefName?: string;
baseRefName?: string;
checks?: PrCheckSummary;
}
/* ── B3 worktree creation (§3.5) ── */
@@ -490,6 +627,44 @@ export interface CreateWorktreeResult {
error?: string;
}
/** Result of DELETE /projects/worktree (W4 remove). `error` is a SAFE message
* only (never raw git stderr, SEC-M10); `status` is the HTTP status to return.
* `path` echoes git's own canonical worktree path that was removed. */
export interface RemoveWorktreeResult {
ok: boolean;
path?: string;
status?: number;
error?: string;
}
/** Result of POST /projects/worktree/prune (W4). `pruned` lists best-effort
* human labels of the stale worktrees git reclaimed (empty = nothing to prune,
* idempotent). `error` is a SAFE message only (SEC-M10). */
export interface PruneWorktreesResult {
ok: boolean;
pruned?: string[];
status?: number;
error?: string;
}
/* ── W4 git write: stage / commit / push (§w4-commit-push) ── */
/** Result of the three W4 git-write routes (POST /projects/git/{stage,commit,push}).
* ONE interface for all three (like CreateWorktreeResult) to keep the shared
* contract small. `error` carries a SAFE message only — never raw git stderr
* (SEC-M10); `status` is the HTTP status to return on failure. Success payloads
* are route-specific and all optional. */
export interface GitOpResult {
ok: boolean;
status?: number; // HTTP status on failure
error?: string; // SAFE message only (never raw git stderr)
staged?: boolean; // stage: direction applied (true = added, false = unstaged)
count?: number; // stage: number of files affected
commit?: string; // commit: short SHA of the new commit
branch?: string; // push: branch that was pushed
remote?: string; // push: remote it was pushed to
}
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).
@@ -508,6 +683,56 @@ export interface UiPrefs {
* permission mode when the server forbids it (SEC-M5). */
export interface UiConfig {
allowAutoMode: boolean;
/** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the
* FE can derive cost-overage warn styling client-side; omitted when disabled. */
costBudgetUsd?: number;
/** W5 fan-out board: server-controlled cap on fan-out lanes so the FE stepper
* max mirrors MAX_FANOUT_LANES. Additive OPTIONAL (older clients default to 6). */
maxFanoutLanes?: number;
}
/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */
/** One session in the "while you were away" digest — a read-side projection of a
* live session plus its latest telemetry/status. All fields derived, no new state. */
export interface DigestSession {
id: string;
title?: string; // last cwd segment
status: ClaudeStatus;
costUsd?: number; // telemetry.costUsd
lastOutputAt?: number;
finished: boolean; // status==='idle' && lastOutputAt > since
needsInput: boolean; // status==='waiting'
stuck: boolean; // status==='stuck'
}
/** GET /digest result — an aggregate over manager.list() since a client's
* last-seen timestamp. Pure read (no new state); empty when no sessions. */
export interface DigestResult {
since: number;
generatedAt: number;
total: number;
finished: number;
needsInput: number;
stuck: number;
working: number;
totalCostUsd: number;
sessions: DigestSession[];
}
/* ── W3 quick-wins (d) recent-commits log (GET /projects/log) ── */
/** One commit from `git log` (NUL-record, US-field delimited). `at` = %ct*1000. */
export interface CommitLogEntry {
hash: string;
at: number;
subject: string;
}
/** GET /projects/log result. `truncated` = more commits exist beyond the cap. */
export interface GitLogResult {
commits: CommitLogEntry[];
truncated: boolean;
}
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */

139
test/auth.test.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* test/auth.test.ts — unit tests for src/http/auth.ts (w5-access-token).
*
* Mirrors the discipline of test/origin.test.ts. Security-critical: the
* constant-time comparison, the cookie flags, and the disabled short-circuit.
*/
import { describe, it, expect } from 'vitest'
import {
AUTH_COOKIE_NAME,
buildSetCookie,
constantTimeEqual,
cookieIsAuthed,
isAuthEnabled,
isHttpsRequest,
parseCookieHeader,
} from '../src/http/auth.js'
const TOKEN = 'super-secret-token-1234' // ≥16, safe charset
// ── constantTimeEqual ─────────────────────────────────────────────────────────
describe('constantTimeEqual', () => {
it('returns true for equal strings', () => {
expect(constantTimeEqual(TOKEN, TOKEN)).toBe(true)
})
it('returns false for unequal same-length strings', () => {
expect(constantTimeEqual('aaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaab')).toBe(false)
})
it('returns false for different-length strings without throwing (fixed-length guard)', () => {
// The SHA-256-to-fixed-32-bytes guard means unequal lengths never throw.
expect(() => constantTimeEqual('short', 'a-much-longer-candidate-value')).not.toThrow()
expect(constantTimeEqual('short', 'a-much-longer-candidate-value')).toBe(false)
})
it('returns false for an empty candidate (present-vs-absent short-circuit)', () => {
expect(constantTimeEqual('', TOKEN)).toBe(false)
expect(constantTimeEqual(TOKEN, '')).toBe(false)
})
it('returns false for an undefined candidate', () => {
expect(constantTimeEqual(undefined, TOKEN)).toBe(false)
expect(constantTimeEqual(TOKEN, undefined)).toBe(false)
})
})
// ── parseCookieHeader ─────────────────────────────────────────────────────────
describe('parseCookieHeader', () => {
it('parses a multi-pair cookie header into a map', () => {
const map = parseCookieHeader('a=1; webterm_auth=xyz; b=2')
expect(map).toEqual({ a: '1', webterm_auth: 'xyz', b: '2' })
})
it('returns {} for a missing or empty header', () => {
expect(parseCookieHeader(undefined)).toEqual({})
expect(parseCookieHeader('')).toEqual({})
})
it('ignores malformed pairs (no = / empty name)', () => {
const map = parseCookieHeader('novalue; =orphan; webterm_auth=ok')
expect(map).toEqual({ webterm_auth: 'ok' })
})
})
// ── isAuthEnabled ─────────────────────────────────────────────────────────────
describe('isAuthEnabled', () => {
it('is false when the token is undefined', () => {
expect(isAuthEnabled({ webtermToken: undefined })).toBe(false)
})
it('is false when the token is an empty string', () => {
expect(isAuthEnabled({ webtermToken: '' })).toBe(false)
})
it('is true when a token is set', () => {
expect(isAuthEnabled({ webtermToken: TOKEN })).toBe(true)
})
})
// ── cookieIsAuthed ────────────────────────────────────────────────────────────
describe('cookieIsAuthed', () => {
it('is true for a correct auth cookie', () => {
expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=${TOKEN}`)).toBe(true)
})
it('is false for a wrong cookie value', () => {
expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=nope-nope-nope-nope`)).toBe(false)
})
it('is false when the auth cookie is absent', () => {
expect(cookieIsAuthed({ webtermToken: TOKEN }, 'other=1')).toBe(false)
expect(cookieIsAuthed({ webtermToken: TOKEN }, undefined)).toBe(false)
})
it('is false when auth is disabled (no token) regardless of cookie', () => {
expect(cookieIsAuthed({ webtermToken: undefined }, `${AUTH_COOKIE_NAME}=anything`)).toBe(false)
})
})
// ── buildSetCookie ────────────────────────────────────────────────────────────
describe('buildSetCookie', () => {
it('includes the token value and the required flags', () => {
const c = buildSetCookie({ webtermToken: TOKEN }, { secure: false })
expect(c).toContain(`${AUTH_COOKIE_NAME}=${TOKEN}`)
expect(c).toContain('Path=/')
expect(c).toContain('Max-Age=')
expect(c).toContain('HttpOnly')
expect(c).toContain('SameSite=Strict')
})
it('omits Secure when opts.secure is false (LAN over http)', () => {
expect(buildSetCookie({ webtermToken: TOKEN }, { secure: false })).not.toContain('Secure')
})
it('includes Secure when opts.secure is true (relay over https)', () => {
expect(buildSetCookie({ webtermToken: TOKEN }, { secure: true })).toContain('; Secure')
})
})
// ── isHttpsRequest ────────────────────────────────────────────────────────────
describe('isHttpsRequest', () => {
it('is true when x-forwarded-proto is https (TLS-terminating edge)', () => {
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https' } })).toBe(true)
})
it('reads the first value of a comma-joined x-forwarded-proto', () => {
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https, http' } })).toBe(true)
})
it('is true when the socket is encrypted (direct TLS)', () => {
expect(isHttpsRequest({ headers: {}, socket: { encrypted: true } })).toBe(true)
})
it('is false for a plain http request', () => {
expect(isHttpsRequest({ headers: {}, socket: { encrypted: false } })).toBe(false)
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'http' } })).toBe(false)
})
})

View File

@@ -299,6 +299,18 @@ describe('loadConfig — session/rate/timer constants', () => {
expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow()
})
it('defaults maxFanoutLanes to 6 (W5)', () => {
expect(loadConfig({}).maxFanoutLanes).toBe(6)
})
it('reads MAX_FANOUT_LANES from env', () => {
expect(loadConfig({ MAX_FANOUT_LANES: '3' }).maxFanoutLanes).toBe(3)
})
it('throws for a negative MAX_FANOUT_LANES (fail-fast, like MAX_SESSIONS)', () => {
expect(() => loadConfig({ MAX_FANOUT_LANES: '-1' })).toThrow()
})
it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => {
expect(loadConfig({}).maxMsgsPerSec).toBe(2000)
expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500)
@@ -595,6 +607,34 @@ describe('loadConfig — v0.7 B1 diff + B2 statusline', () => {
})
})
// ── W3 quick-wins (b) cost budget guard ───────────────────────────────────────
describe('loadConfig — W3 COST_BUDGET_USD', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('defaults costBudgetUsd to 0 (disabled) when unset', () => {
expect(loadConfig({}).costBudgetUsd).toBe(0)
})
it('parses a float dollar value', () => {
expect(loadConfig({ COST_BUDGET_USD: '5.50' }).costBudgetUsd).toBe(5.5)
})
it('accepts an explicit 0 (disabled)', () => {
expect(loadConfig({ COST_BUDGET_USD: '0' }).costBudgetUsd).toBe(0)
})
it('throws for a non-numeric value', () => {
expect(() => loadConfig({ COST_BUDGET_USD: 'abc' })).toThrow(/COST_BUDGET_USD/)
})
it('throws for a negative value', () => {
expect(() => loadConfig({ COST_BUDGET_USD: '-1' })).toThrow(/COST_BUDGET_USD/)
})
})
describe('loadConfig — v0.7 B3 worktree', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
@@ -622,6 +662,39 @@ describe('loadConfig — v0.7 B3 worktree', () => {
})
})
describe('loadConfig — W4 git write (stage / commit / push)', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('GIT_OPS_ENABLED: default true, parses on/off', () => {
expect(loadConfig({}).gitOpsEnabled).toBe(true)
expect(loadConfig({ GIT_OPS_ENABLED: '0' }).gitOpsEnabled).toBe(false)
expect(loadConfig({ GIT_OPS_ENABLED: 'false' }).gitOpsEnabled).toBe(false)
})
it('GIT_OPS_TIMEOUT_MS: default 10000, reads from env', () => {
expect(loadConfig({}).gitOpsTimeoutMs).toBe(10_000)
expect(loadConfig({ GIT_OPS_TIMEOUT_MS: '5000' }).gitOpsTimeoutMs).toBe(5_000)
})
it('GIT_PUSH_TIMEOUT_MS: default 120000, reads from env', () => {
expect(loadConfig({}).gitPushTimeoutMs).toBe(120_000)
expect(loadConfig({ GIT_PUSH_TIMEOUT_MS: '30000' }).gitPushTimeoutMs).toBe(30_000)
})
it('COMMIT_MSG_MAX_LEN: default 5000, reads from env', () => {
expect(loadConfig({}).commitMsgMaxLen).toBe(5_000)
expect(loadConfig({ COMMIT_MSG_MAX_LEN: '200' }).commitMsgMaxLen).toBe(200)
})
it('throws for a non-integer numeric var', () => {
expect(() => loadConfig({ GIT_OPS_TIMEOUT_MS: 'slow' })).toThrow(/GIT_OPS_TIMEOUT_MS/)
expect(() => loadConfig({ COMMIT_MSG_MAX_LEN: 'lots' })).toThrow(/COMMIT_MSG_MAX_LEN/)
})
})
describe('loadConfig — v0.7 B4 permission mode', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
@@ -730,3 +803,86 @@ describe('loadConfig — v0.7 all new fields present in frozen result', () => {
expect(cfg).toHaveProperty('allowAutoMode')
})
})
// ── W2 inject-queue config ────────────────────────────────────────────────────
describe('loadConfig — W2 inject queue', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('defaults: queueEnabled true, queueMaxItems 10, queueItemMaxBytes 4096, queueSettleMs 1500', () => {
const cfg = loadConfig({})
expect(cfg.queueEnabled).toBe(true)
expect(cfg.queueMaxItems).toBe(10)
expect(cfg.queueItemMaxBytes).toBe(4096)
expect(cfg.queueSettleMs).toBe(1500)
})
it('reads env overrides', () => {
const cfg = loadConfig({
QUEUE_ENABLED: '0',
QUEUE_MAX_ITEMS: '3',
QUEUE_ITEM_MAX_BYTES: '256',
QUEUE_SETTLE_MS: '500',
})
expect(cfg.queueEnabled).toBe(false)
expect(cfg.queueMaxItems).toBe(3)
expect(cfg.queueItemMaxBytes).toBe(256)
expect(cfg.queueSettleMs).toBe(500)
})
it('throws for a negative QUEUE_MAX_ITEMS (fail-fast)', () => {
expect(() => loadConfig({ QUEUE_MAX_ITEMS: '-1' })).toThrow()
})
it('throws for a non-integer QUEUE_SETTLE_MS', () => {
expect(() => loadConfig({ QUEUE_SETTLE_MS: 'soon' })).toThrow()
})
})
describe('loadConfig — w5-access-token (WEBTERM_TOKEN)', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('is undefined when WEBTERM_TOKEN is unset (auth disabled — LAN zero-config)', () => {
expect(loadConfig({}).webtermToken).toBeUndefined()
})
it('is undefined when WEBTERM_TOKEN is the empty string (treated as unset)', () => {
expect(loadConfig({ WEBTERM_TOKEN: '' }).webtermToken).toBeUndefined()
})
it('stores a valid token verbatim (≥16 chars, safe charset)', () => {
const t = 'super-secret-token-1234'
expect(loadConfig({ WEBTERM_TOKEN: t }).webtermToken).toBe(t)
})
it('throws for a too-short token (< 16 chars)', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'abc' })).toThrow(/WEBTERM_TOKEN/)
})
it('throws for a token with an unsafe char (semicolon → Set-Cookie injection)', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'has;semicolon;inside0' })).toThrow(/WEBTERM_TOKEN/)
})
it('throws for a token with a space', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'has a space in it here' })).toThrow(/WEBTERM_TOKEN/)
})
it('throws for a token with a control char', () => {
expect(() => loadConfig({ WEBTERM_TOKEN: 'ctrlchar-injection00' })).toThrow(/WEBTERM_TOKEN/)
})
it('never leaks the token value in the validation error message (secret hygiene)', () => {
const badToken = 'secretbutbad;value00'
expect(() => loadConfig({ WEBTERM_TOKEN: badToken })).toThrow()
try {
loadConfig({ WEBTERM_TOKEN: badToken })
} catch (e) {
expect(String((e as Error).message)).not.toContain(badToken)
}
})
})

View File

@@ -19,6 +19,10 @@ const {
renderDiffFile,
renderDiff,
mountDiffViewer,
isGitOpResult,
postStage,
postCommit,
postPush,
} = await import('../public/diff.js')
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -129,6 +133,24 @@ describe('normalizeDiffResult', () => {
const result = normalizeDiffResult({ files: [], staged: false, truncated: true })
expect(result?.truncated).toBe(true)
})
it('passes through an optional base revision (FR-B1.9)', () => {
const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 'main' })
expect(result).not.toBeNull()
expect(result?.base).toBe('main')
})
it('leaves base undefined when the payload omits it', () => {
const result = normalizeDiffResult({ files: [], staged: false, truncated: false })
expect(result).not.toBeNull()
expect(result?.base).toBeUndefined()
})
it('ignores a non-string base (coerces to undefined)', () => {
const result = normalizeDiffResult({ files: [], staged: false, truncated: false, base: 123 })
expect(result).not.toBeNull()
expect(result?.base).toBeUndefined()
})
})
// ── fetchDiff ─────────────────────────────────────────────────────────────────
@@ -141,7 +163,7 @@ describe('fetchDiff', () => {
}))
vi.stubGlobal('fetch', mockFetch)
const result = await fetchDiff('/some/repo', false)
const result = await fetchDiff('/some/repo', { staged: false })
expect(mockFetch).toHaveBeenCalledOnce()
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('/projects/diff')
@@ -157,20 +179,52 @@ describe('fetchDiff', () => {
}))
vi.stubGlobal('fetch', mockFetch)
await fetchDiff('/repo', true)
await fetchDiff('/repo', { staged: true })
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('staged=true')
})
it('defaults to staged=false when called with no options', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
vi.stubGlobal('fetch', mockFetch)
await fetchDiff('/repo')
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('staged=false')
})
it('builds a &base=<rev> URL (omitting staged) when base is set (FR-B1.9)', async () => {
const mockFetch = vi.fn(async () => ({
ok: true,
json: async () => makeResult({ base: 'main' }),
}))
vi.stubGlobal('fetch', mockFetch)
const result = await fetchDiff('/repo', { base: 'main' })
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain('base=main')
expect(url).not.toContain('staged=')
expect(result?.base).toBe('main')
})
it('URL-encodes a base containing slashes (feature/x)', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
vi.stubGlobal('fetch', mockFetch)
await fetchDiff('/repo', { base: 'feature/x' })
const url = mockFetch.mock.calls[0]?.[0] as string
expect(url).toContain(`base=${encodeURIComponent('feature/x')}`)
})
it('returns null on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
const result = await fetchDiff('/repo', false)
const result = await fetchDiff('/repo', { staged: false })
expect(result).toBeNull()
})
it('returns null on fetch error (network failure)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
const result = await fetchDiff('/repo', false)
const result = await fetchDiff('/repo', { staged: false })
expect(result).toBeNull()
})
@@ -179,7 +233,7 @@ describe('fetchDiff', () => {
ok: true,
json: async () => ({ not: 'a diff result' }),
})))
const result = await fetchDiff('/repo', false)
const result = await fetchDiff('/repo', { staged: false })
expect(result).toBeNull()
})
})
@@ -480,4 +534,312 @@ describe('mountDiffViewer', () => {
await expect(new Promise((r) => setTimeout(r, 0))).resolves.toBeUndefined()
handle.destroy()
})
// ── FR-B1.9: base-branch picker ─────────────────────────────────────────────
it('renders NO base picker when bases is absent (backward-compatible)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
expect(container.querySelector('.df-base-select')).toBeNull()
handle.destroy()
})
it('renders NO base picker when bases is empty', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { bases: [] })
await new Promise((r) => setTimeout(r, 0))
expect(container.querySelector('.df-base-select')).toBeNull()
handle.destroy()
})
it('renders a <select> with "Working tree" + one option per base', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => makeResult() })))
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { bases: ['main', 'dev'] })
await new Promise((r) => setTimeout(r, 0))
const select = container.querySelector('.df-base-select') as HTMLSelectElement | null
expect(select).not.toBeNull()
const labels = Array.from(select!.options).map((o) => o.textContent)
expect(labels).toEqual(['Working tree', 'main', 'dev'])
handle.destroy()
})
it('selecting a base fetches with base=<rev> and disables the Working/Staged tabs', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult({ base: 'main' }) }))
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { bases: ['main', 'dev'] })
await new Promise((r) => setTimeout(r, 0))
const select = container.querySelector('.df-base-select') as HTMLSelectElement
select.value = 'main'
select.dispatchEvent(new Event('change'))
await new Promise((r) => setTimeout(r, 0))
const lastUrl = mockFetch.mock.calls.at(-1)?.[0] as string
expect(lastUrl).toContain('base=main')
expect(lastUrl).not.toContain('staged=')
const workingBtn = container.querySelector('.df-tab') as HTMLButtonElement
const stagedBtn = container.querySelectorAll('.df-tab')[1] as HTMLButtonElement
expect(workingBtn.disabled).toBe(true)
expect(stagedBtn.disabled).toBe(true)
handle.destroy()
})
it('selecting "Working tree" restores a staged-mode fetch and re-enables tabs', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => makeResult() }))
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
await new Promise((r) => setTimeout(r, 0))
const select = container.querySelector('.df-base-select') as HTMLSelectElement
// Enter base mode …
select.value = 'main'
select.dispatchEvent(new Event('change'))
await new Promise((r) => setTimeout(r, 0))
// … then back to Working tree.
select.value = ''
select.dispatchEvent(new Event('change'))
await new Promise((r) => setTimeout(r, 0))
const lastUrl = mockFetch.mock.calls.at(-1)?.[0] as string
expect(lastUrl).toContain('staged=false')
expect(lastUrl).not.toContain('base=')
const workingBtn = container.querySelector('.df-tab') as HTMLButtonElement
expect(workingBtn.disabled).toBe(false)
handle.destroy()
})
})
// ── W4 git-write POST helpers ─────────────────────────────────────────────────
describe('isGitOpResult', () => {
it('accepts an object with a boolean ok, rejects everything else', () => {
expect(isGitOpResult({ ok: true })).toBe(true)
expect(isGitOpResult({ ok: false, error: 'x' })).toBe(true)
expect(isGitOpResult(null)).toBe(false)
expect(isGitOpResult({ ok: 'yes' })).toBe(false)
expect(isGitOpResult('nope')).toBe(false)
})
})
describe('postStage / postCommit / postPush', () => {
it('postStage POSTs the path/files/stage body to /projects/git/stage', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, staged: true, count: 1 }) }))
vi.stubGlobal('fetch', mockFetch)
const r = await postStage('/repo', ['a.ts', 'b.ts'], true)
expect(r).toMatchObject({ ok: true, staged: true, count: 1 })
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/projects/git/stage')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', files: ['a.ts', 'b.ts'], stage: true })
})
it('postCommit POSTs the message to /projects/git/commit', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, commit: 'abc1234' }) }))
vi.stubGlobal('fetch', mockFetch)
const r = await postCommit('/repo', 'hello')
expect(r).toMatchObject({ ok: true, commit: 'abc1234' })
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('/projects/git/commit')
expect(JSON.parse(init.body as string)).toEqual({ path: '/repo', message: 'hello' })
})
it('postPush POSTs the path to /projects/git/push', async () => {
const mockFetch = vi.fn(async () => ({ ok: true, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }))
vi.stubGlobal('fetch', mockFetch)
const r = await postPush('/repo')
expect(r).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
expect((mockFetch.mock.calls[0] as [string])[0]).toBe('/projects/git/push')
})
it('synthesizes a failure {ok:false,status} when the body is not a GitOpResult', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 429, json: async () => ({}) })))
const r = await postStage('/repo', ['a'], true)
expect(r).toMatchObject({ ok: false, status: 429 })
})
it('returns null on a network error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('net') }))
expect(await postPush('/repo')).toBeNull()
})
})
// ── W4 mountDiffViewer: stage toggles + commit/push bar ───────────────────────
describe('mountDiffViewer — W4 stage / commit / push', () => {
function makeContainer(): HTMLDivElement {
return document.createElement('div')
}
/** A fetch mock that routes GET /projects/diff → a diff, and each POST git route
* → a caller-supplied response (defaults to ok:true). */
function routingFetch(gitResponses: Record<string, unknown> = {}): ReturnType<typeof vi.fn> {
return vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
const body = gitResponses[url] ?? { ok: true }
return { ok: true, status: 200, json: async () => body }
}
return { ok: true, status: 200, json: async () => makeResult({ staged: false }) }
})
}
it('renders a "Stage" button on each file row in the working view', async () => {
vi.stubGlobal('fetch', routingFetch())
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const btn = container.querySelector('.df-file-stage')
expect(btn).not.toBeNull()
expect(btn?.textContent).toBe('Stage')
handle.destroy()
})
it('labels the toggle "Unstage" in the staged view', async () => {
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') return { ok: true, status: 200, json: async () => ({ ok: true }) }
return { ok: true, status: 200, json: async () => makeResult({ staged: true }) }
})
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
handle.showStaged()
await new Promise((r) => setTimeout(r, 0))
expect(container.querySelector('.df-file-stage')?.textContent).toBe('Unstage')
handle.destroy()
})
it('clicking Stage POSTs {path,files,stage:true} then re-fetches the diff', async () => {
const mockFetch = routingFetch()
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const initialCalls = mockFetch.mock.calls.length
;(container.querySelector('.df-file-stage') as HTMLButtonElement).click()
await new Promise((r) => setTimeout(r, 0))
const post = mockFetch.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST')
expect(post?.[0]).toBe('/projects/git/stage')
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toMatchObject({
path: '/repo',
files: ['src/foo.ts'],
stage: true,
})
// Re-fetched the diff after the successful stage.
expect(mockFetch.mock.calls.length).toBeGreaterThan(initialCalls + 1)
handle.destroy()
})
it('disables Commit until the message is non-empty, then POSTs the commit', async () => {
const mockFetch = routingFetch({ '/projects/git/commit': { ok: true, commit: 'deadbee' } })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const commitBtn = container.querySelector('.df-commit-btn') as HTMLButtonElement
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
expect(commitBtn.disabled).toBe(true)
msg.value = 'phone commit'
msg.dispatchEvent(new Event('input'))
expect(commitBtn.disabled).toBe(false)
commitBtn.click()
await new Promise((r) => setTimeout(r, 0))
const post = mockFetch.mock.calls.find((c) => c[0] === '/projects/git/commit')
expect(post).toBeDefined()
expect(JSON.parse((post?.[1] as RequestInit).body as string)).toEqual({ path: '/repo', message: 'phone commit' })
// Message cleared on success.
expect(msg.value).toBe('')
handle.destroy()
})
it('shows a commit failure via textContent with zero innerHTML injection (SEC-H4)', async () => {
const evil = '<script>alert(1)</script>'
const mockFetch = routingFetch({ '/projects/git/commit': { ok: false, status: 409, error: evil } })
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const msg = container.querySelector('.df-commit-msg') as HTMLTextAreaElement
msg.value = 'x'
msg.dispatchEvent(new Event('input'))
;(container.querySelector('.df-commit-btn') as HTMLButtonElement).click()
await new Promise((r) => setTimeout(r, 0))
const status = container.querySelector('.df-op-status') as HTMLElement
expect(status.textContent).toContain(evil)
expect(container.querySelectorAll('script').length).toBe(0)
handle.destroy()
})
it('Push POSTs /projects/git/push and reflects busy/disabled while in flight', async () => {
let resolvePush: (v: unknown) => void = () => undefined
const pending = new Promise((r) => {
resolvePush = r
})
const mockFetch = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST' && url === '/projects/git/push') {
await pending
return { ok: true, status: 200, json: async () => ({ ok: true, branch: 'main', remote: 'origin' }) }
}
return { ok: true, status: 200, json: async () => makeResult() }
})
vi.stubGlobal('fetch', mockFetch)
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', {})
await new Promise((r) => setTimeout(r, 0))
const pushBtn = container.querySelector('.df-push-btn') as HTMLButtonElement
pushBtn.click()
await new Promise((r) => setTimeout(r, 0))
// In flight → disabled.
expect(pushBtn.disabled).toBe(true)
resolvePush(undefined)
await new Promise((r) => setTimeout(r, 0))
expect(pushBtn.disabled).toBe(false)
expect((container.querySelector('.df-op-status') as HTMLElement).textContent).toContain('main')
handle.destroy()
})
it('hides the commit bar in base-compare mode', async () => {
vi.stubGlobal('fetch', routingFetch())
const container = makeContainer()
const handle = mountDiffViewer(container, '/repo', { bases: ['main'] })
await new Promise((r) => setTimeout(r, 0))
const bar = container.querySelector('.df-commitbar') as HTMLElement
expect(bar.style.display).toBe('') // visible in working mode
const select = container.querySelector('.df-base-select') as HTMLSelectElement
select.value = 'main'
select.dispatchEvent(new Event('change'))
await new Promise((r) => setTimeout(r, 0))
expect(bar.style.display).toBe('none') // hidden in base mode
// No stage buttons in base mode either.
expect(container.querySelector('.df-file-stage')).toBeNull()
handle.destroy()
})
})

165
test/digest.test.ts Normal file
View File

@@ -0,0 +1,165 @@
// @vitest-environment jsdom
/**
* test/digest.test.ts (W3 quick-wins c) — reconnect digest banner (public/digest.ts).
*
* Pure helpers (normalizeDigest / digestSummary / renderDigestBanner) + the
* mountDigest wiring with a mocked fetch: fetches with the stored last-seen,
* shows a banner only when something happened, dismiss advances last-seen + hides,
* and a fetch failure shows no banner (best-effort). localStorage comes from the
* shared jsdom polyfill (vitest.config setupFiles).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { DigestResult } from '../src/types.js'
import {
normalizeDigest,
digestSummary,
digestHighlightCount,
renderDigestBanner,
getLastSeen,
setLastSeen,
mountDigest,
} from '../public/digest.js'
function makeDigest(over: Partial<DigestResult> = {}): DigestResult {
return {
since: 0,
generatedAt: 5000,
total: 0,
finished: 0,
needsInput: 0,
stuck: 0,
working: 0,
totalCostUsd: 0,
sessions: [],
...over,
}
}
/** Install a fetch mock returning `body` (or rejecting when `body` is null). */
function mockFetch(body: unknown, ok = true): ReturnType<typeof vi.fn> {
const fn = vi.fn(async () => {
if (body === null) throw new Error('network down')
return { ok, json: async () => body } as Response
})
vi.stubGlobal('fetch', fn)
return fn
}
beforeEach(() => {
localStorage.clear()
document.body.innerHTML = ''
})
afterEach(() => {
vi.unstubAllGlobals()
})
// ── pure helpers ──────────────────────────────────────────────────────────────
describe('normalizeDigest', () => {
it('returns null for a non-object / missing generatedAt', () => {
expect(normalizeDigest(null)).toBeNull()
expect(normalizeDigest('x')).toBeNull()
expect(normalizeDigest({ total: 1 })).toBeNull()
})
it('coerces a well-formed response', () => {
const d = normalizeDigest(makeDigest({ finished: 2, totalCostUsd: 1.5 }))
expect(d?.finished).toBe(2)
expect(d?.totalCostUsd).toBe(1.5)
expect(Array.isArray(d?.sessions)).toBe(true)
})
it('defaults missing numeric counts to 0 and non-array sessions to []', () => {
const d = normalizeDigest({ generatedAt: 10 })
expect(d?.finished).toBe(0)
expect(d?.sessions).toEqual([])
})
})
describe('digestSummary / digestHighlightCount', () => {
it('counts finished + needsInput + stuck', () => {
expect(digestHighlightCount(makeDigest({ finished: 2, needsInput: 1, stuck: 1, working: 9 }))).toBe(4)
})
it('builds a compact summary of only the non-zero buckets', () => {
const s = digestSummary(makeDigest({ finished: 2, stuck: 1 }))
expect(s).toContain('2 finished')
expect(s).toContain('1 stuck')
expect(s).not.toContain('waiting')
})
})
describe('renderDigestBanner', () => {
it('returns null when nothing worth showing', () => {
expect(renderDigestBanner(makeDigest({ working: 3 }), () => {})).toBeNull()
})
it('renders a dismissible banner and wires the × button', () => {
const onDismiss = vi.fn()
const banner = renderDigestBanner(makeDigest({ finished: 1 }), onDismiss)
expect(banner?.className).toContain('wya-banner')
;(banner?.querySelector('.wya-dismiss') as HTMLButtonElement).click()
expect(onDismiss).toHaveBeenCalled()
})
it('renders an attacker-influenced summary as inert text (SEC-H5)', () => {
const banner = renderDigestBanner(makeDigest({ finished: 1 }), () => {})
expect(banner?.querySelectorAll('script').length).toBe(0)
})
})
describe('getLastSeen / setLastSeen', () => {
it('defaults to 0 and round-trips a value', () => {
expect(getLastSeen()).toBe(0)
setLastSeen(1234)
expect(getLastSeen()).toBe(1234)
})
})
// ── mountDigest (wiring) ──────────────────────────────────────────────────────
describe('mountDigest', () => {
it('fetches with the stored last-seen watermark', async () => {
setLastSeen(500)
const fetchFn = mockFetch(makeDigest({ finished: 1 }))
await mountDigest(document.body)
expect(fetchFn).toHaveBeenCalledWith('/digest?since=500')
})
it('shows a banner when something happened', async () => {
mockFetch(makeDigest({ finished: 1 }))
await mountDigest(document.body)
expect(document.body.querySelector('.wya-banner')).not.toBeNull()
})
it('shows NO banner when nothing happened', async () => {
mockFetch(makeDigest({ working: 2 }))
await mountDigest(document.body)
expect(document.body.querySelector('.wya-banner')).toBeNull()
})
it('advances last-seen to generatedAt after a fetch', async () => {
mockFetch(makeDigest({ generatedAt: 8888, finished: 1 }))
await mountDigest(document.body)
expect(getLastSeen()).toBe(8888)
})
it('dismiss updates last-seen and removes the banner', async () => {
mockFetch(makeDigest({ generatedAt: 7777, needsInput: 1 }))
await mountDigest(document.body)
const banner = document.body.querySelector('.wya-banner')
expect(banner).not.toBeNull()
;(banner!.querySelector('.wya-dismiss') as HTMLButtonElement).click()
expect(document.body.querySelector('.wya-banner')).toBeNull()
expect(getLastSeen()).toBe(7777)
})
it('shows no banner on a fetch failure (best-effort)', async () => {
mockFetch(null)
const banner = await mountDigest(document.body)
expect(banner).toBeNull()
expect(document.body.querySelector('.wya-banner')).toBeNull()
})
})

View File

@@ -9,7 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { openInEditor } from '../src/http/editor.js'
import { openInEditor, openFileInEditor, isGotoEditor } from '../src/http/editor.js'
import type { Config } from '../src/types.js'
function cfg(editorCmd: string): Config {
@@ -66,3 +66,104 @@ describe('openInEditor — launch', () => {
expect(r.status).toBe(204)
})
})
// ── W1: openFileInEditor (open a FILE at a line) ──────────────────────────────
/** Poll for a file to exist + be non-empty (the editor spawn is fire-and-forget). */
async function waitForFile(p: string, timeoutMs = 3_000): Promise<string> {
const start = Date.now()
for (;;) {
try {
const content = await fs.readFile(p, 'utf8')
if (content.length > 0) return content
} catch {
// not written yet
}
if (Date.now() - start > timeoutMs) throw new Error(`timeout waiting for ${p}`)
await new Promise((r) => setTimeout(r, 25))
}
}
describe('isGotoEditor', () => {
it('is true for the VS Code family (code/cursor/windsurf/codium)', () => {
expect(isGotoEditor('code')).toBe(true)
expect(isGotoEditor('/usr/local/bin/cursor')).toBe(true)
expect(isGotoEditor('windsurf')).toBe(true)
expect(isGotoEditor('codium')).toBe(true)
expect(isGotoEditor('code.cmd')).toBe(true) // Windows shim
})
it('is false for editors without --goto (vim/nano/true)', () => {
expect(isGotoEditor('vim')).toBe(false)
expect(isGotoEditor('nano')).toBe(false)
expect(isGotoEditor('true')).toBe(false)
})
})
describe('openFileInEditor — validation', () => {
it('rejects a missing / empty / non-string file with 400', async () => {
expect((await openFileInEditor(cfg('true'), undefined)).status).toBe(400)
expect((await openFileInEditor(cfg('true'), '')).status).toBe(400)
expect((await openFileInEditor(cfg('true'), ' ')).status).toBe(400)
expect((await openFileInEditor(cfg('true'), 123)).status).toBe(400)
})
it('rejects a relative file with 400', async () => {
expect((await openFileInEditor(cfg('true'), 'rel/a.ts')).status).toBe(400)
})
it('returns 404 for a non-existent absolute file', async () => {
const r = await openFileInEditor(cfg('true'), path.join(tmpDir, 'nope.ts'))
expect(r.status).toBe(404)
})
it('returns 400 when the path is a directory, not a file', async () => {
const r = await openFileInEditor(cfg('true'), tmpDir)
expect(r.status).toBe(400)
expect(r.error).toContain('not a file')
})
it('rejects a non-integer / zero / out-of-range / non-number line with 400', async () => {
expect((await openFileInEditor(cfg('true'), tmpFile, 1.5)).status).toBe(400)
expect((await openFileInEditor(cfg('true'), tmpFile, 0)).status).toBe(400)
expect((await openFileInEditor(cfg('true'), tmpFile, 1_000_000_001)).status).toBe(400)
expect((await openFileInEditor(cfg('true'), tmpFile, 'x')).status).toBe(400)
})
})
describe('openFileInEditor — launch', () => {
it('spawns the editor for a valid file with no line (204)', async () => {
const r = await openFileInEditor(cfg('true'), tmpFile)
expect(r.ok).toBe(true)
expect(r.status).toBe(204)
})
it('spawns the editor for a valid file + line (204)', async () => {
const r = await openFileInEditor(cfg('true'), tmpFile, 42)
expect(r.status).toBe(204)
})
it('passes `--goto <file>:<line>` for a goto-capable editor (basename code)', async () => {
const argsOut = path.join(tmpDir, 'goto-args.txt')
const recorder = path.join(tmpDir, 'code') // basename 'code' → isGotoEditor true
await fs.writeFile(recorder, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsOut}"\n`)
await fs.chmod(recorder, 0o755)
const r = await openFileInEditor(cfg(recorder), tmpFile, 42)
expect(r.status).toBe(204)
const recorded = (await waitForFile(argsOut)).trim().split('\n')
expect(recorded).toEqual(['--goto', `${tmpFile}:42`])
})
it('passes only the bare file for a non-goto editor (drops the line)', async () => {
const argsOut = path.join(tmpDir, 'bare-args.txt')
const recorder = path.join(tmpDir, 'myeditor') // basename not in the goto set
await fs.writeFile(recorder, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsOut}"\n`)
await fs.chmod(recorder, 0o755)
const r = await openFileInEditor(cfg(recorder), tmpFile, 42)
expect(r.status).toBe(204)
const recorded = (await waitForFile(argsOut)).trim().split('\n')
expect(recorded).toEqual([tmpFile])
})
})

128
test/fanout.test.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* test/fanout.test.ts (W5) — pure launch-command + branch-slug builders.
*
* The load-bearing case is shellSingleQuote: the prompt is typed as raw bytes into
* a lane's shell before `claude` parses it, so it must become one literal argv
* element — no shell metacharacter may execute.
*/
import { describe, it, expect } from 'vitest'
import {
shellSingleQuote,
sanitizePrompt,
buildFanoutCmd,
laneBranch,
slugify,
FANOUT_PROMPT_MAX,
} from '../public/fanout.js'
// Local mirror of validateBranchNameClient's rules (projects.ts) — inlined so this
// stays a pure node test (importing projects.js would drag in @xterm/xterm).
function isValidBranch(branch: string): boolean {
if (!branch || branch.length > 250) return false
if (/[\x00-\x20\x7f]/.test(branch)) return false
if (branch.startsWith('-') || branch.includes('..') || branch.endsWith('.lock')) return false
if (/[~^:?*[\\]/.test(branch)) return false
if (branch.includes('@{')) return false
if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) return false
return true
}
describe('shellSingleQuote', () => {
it('single-quotes a plain string', () => {
expect(shellSingleQuote('fix bug')).toBe(`'fix bug'`)
})
it("escapes an embedded single quote with the '\\'' sequence", () => {
expect(shellSingleQuote("it's ok")).toBe(`'it'\\''s ok'`)
})
it('neutralizes shell metacharacters as literal text', () => {
// $(...), backticks, ;, && all sit inside single quotes → passed verbatim.
const dangerous = 'a; rm -rf / && echo $(whoami) `id`'
const quoted = shellSingleQuote(dangerous)
expect(quoted.startsWith("'")).toBe(true)
expect(quoted.endsWith("'")).toBe(true)
// No unescaped single quote can break out of the quoting.
expect(quoted.slice(1, -1).includes("'")).toBe(false)
})
})
describe('sanitizePrompt', () => {
it('collapses newlines (\\n, \\r\\n) to single spaces', () => {
expect(sanitizePrompt('a\nb\r\nc')).toBe('a b c')
})
it('collapses a bare carriage return too (would submit a partial line)', () => {
expect(sanitizePrompt('a\rb')).toBe('a b')
})
it('trims surrounding whitespace', () => {
expect(sanitizePrompt(' hello ')).toBe('hello')
})
it('caps the length at FANOUT_PROMPT_MAX', () => {
const long = 'x'.repeat(FANOUT_PROMPT_MAX + 500)
expect(sanitizePrompt(long)).toHaveLength(FANOUT_PROMPT_MAX)
})
})
describe('buildFanoutCmd', () => {
it('adds --permission-mode for a non-default mode and ends with \\r', () => {
expect(buildFanoutCmd('fix bug', 'plan', true)).toBe(`claude --permission-mode plan 'fix bug'\r`)
})
it('omits the flag for the default mode', () => {
expect(buildFanoutCmd('fix bug', 'default', true)).toBe(`claude 'fix bug'\r`)
})
it('downgrades auto to default when the server forbids it (SEC-M5)', () => {
expect(buildFanoutCmd('fix bug', 'auto', false)).toBe(`claude 'fix bug'\r`)
})
it('honors auto when the server allows it', () => {
expect(buildFanoutCmd('fix bug', 'auto', true)).toBe(`claude --permission-mode auto 'fix bug'\r`)
})
it('shell-quotes an injection-style prompt into one argv element', () => {
const cmd = buildFanoutCmd('rm -rf $(pwd); echo hi', 'default', false)
expect(cmd).toBe(`claude 'rm -rf $(pwd); echo hi'\r`)
})
it('collapses a multi-line prompt before quoting', () => {
expect(buildFanoutCmd('line1\nline2', 'default', false)).toBe(`claude 'line1 line2'\r`)
})
})
describe('laneBranch', () => {
it('builds `${base}-lane-${i}`', () => {
expect(laneBranch('feat-x', 3)).toBe('feat-x-lane-3')
})
it('produces a valid git branch name', () => {
expect(isValidBranch(laneBranch('feat-x', 3))).toBe(true)
expect(isValidBranch(laneBranch(slugify('Add dark mode!'), 1))).toBe(true)
})
})
describe('slugify', () => {
it('lowercases and dash-collapses non-alphanumeric runs', () => {
expect(slugify('Add Dark Mode!')).toBe('add-dark-mode')
})
it('strips leading/trailing dashes', () => {
expect(slugify(' ...hello... ')).toBe('hello')
})
it('falls back to "fanout" for an empty/symbol-only prompt', () => {
expect(slugify('!!!')).toBe('fanout')
expect(slugify('')).toBe('fanout')
})
it('caps the slug length and stays a valid branch base', () => {
const slug = slugify('a'.repeat(100))
expect(slug.length).toBeLessThanOrEqual(40)
expect(isValidBranch(slug)).toBe(true)
})
})

237
test/gh-chip.test.ts Normal file
View File

@@ -0,0 +1,237 @@
// @vitest-environment jsdom
/**
* test/gh-chip.test.ts (W3 PR + CI status chip) — render-only frontend module.
*
* Covers normalizePrStatus, chipText, renderPrChip, fetchPrStatus, mountPrChip.
* Security: SEC-H4 — all chip content via textContent; a PR title with markup is
* rendered as literal text (no element injection).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { PrStatus } from '../src/types.js'
const {
normalizePrStatus,
chipText,
renderPrChip,
fetchPrStatus,
mountPrChip,
} = await import('../public/gh-chip.js')
function okStatus(over: Partial<PrStatus> = {}): PrStatus {
return {
availability: 'ok',
number: 12,
title: 'A PR',
url: 'https://github.com/o/r/pull/12',
state: 'open',
isDraft: false,
mergeable: 'mergeable',
checks: { total: 5, passing: 5, failing: 0, pending: 0 },
...over,
}
}
// ── normalizePrStatus ─────────────────────────────────────────────────────────
describe('normalizePrStatus', () => {
it('round-trips a valid ok object', () => {
const s = normalizePrStatus(okStatus())
expect(s).not.toBeNull()
expect(s!.availability).toBe('ok')
expect(s!.number).toBe(12)
expect(s!.state).toBe('open')
expect(s!.mergeable).toBe('mergeable')
expect(s!.checks).toEqual({ total: 5, passing: 5, failing: 0, pending: 0 })
})
it('accepts every degrade availability', () => {
for (const availability of ['no-pr', 'not-installed', 'unauthenticated', 'disabled', 'error']) {
expect(normalizePrStatus({ availability })?.availability).toBe(availability)
}
})
it('returns null for a non-object or an unknown availability', () => {
expect(normalizePrStatus(null)).toBeNull()
expect(normalizePrStatus(42)).toBeNull()
expect(normalizePrStatus('ok')).toBeNull()
expect(normalizePrStatus({})).toBeNull()
expect(normalizePrStatus({ availability: 'bogus' })).toBeNull()
})
it('drops malformed optional fields but keeps availability', () => {
const s = normalizePrStatus({ availability: 'ok', number: 'nope', state: 'weird', checks: 5 })
expect(s?.availability).toBe('ok')
expect(s?.number).toBeUndefined()
expect(s?.state).toBeUndefined()
expect(s?.checks).toBeUndefined()
})
})
// ── chipText ──────────────────────────────────────────────────────────────────
describe('chipText', () => {
it('renders an open PR with all checks passing', () => {
const t = chipText(okStatus())!
expect(t.label).toBe('PR #12 ✓ 5/5')
expect(t.cls).toContain('proj-pr-open')
expect(t.cls).toContain('proj-pr-checks-ok')
})
it('marks failing checks with ✕ and the fail class', () => {
const t = chipText(okStatus({ checks: { total: 5, passing: 3, failing: 2, pending: 0 } }))!
expect(t.label).toBe('PR #12 ✕ 3/5')
expect(t.cls).toContain('proj-pr-checks-fail')
})
it('marks pending checks with ⧗ and the pending class', () => {
const t = chipText(okStatus({ checks: { total: 4, passing: 1, failing: 0, pending: 3 } }))!
expect(t.label).toBe('PR #12 ⧗ 1/4')
expect(t.cls).toContain('proj-pr-checks-pending')
})
it('adds a "⚠ conflicts" marker + class for a conflicting PR', () => {
const t = chipText(okStatus({ mergeable: 'conflicting' }))!
expect(t.label).toContain('⚠ conflicts')
expect(t.cls).toContain('proj-pr-conflict')
})
it('labels a draft PR', () => {
const t = chipText(okStatus({ isDraft: true }))!
expect(t.label).toContain('Draft #12')
expect(t.cls).toContain('proj-pr-draft')
})
it('labels merged / closed PRs', () => {
expect(chipText(okStatus({ state: 'merged' }))!.label).toContain('Merged #12')
expect(chipText(okStatus({ state: 'closed' }))!.cls).toContain('proj-pr-closed')
})
it('omits the checks segment when there are zero checks', () => {
const t = chipText(okStatus({ checks: { total: 0, passing: 0, failing: 0, pending: 0 } }))!
expect(t.label).toBe('PR #12')
})
it('maps each degrade availability to its label', () => {
expect(chipText({ availability: 'no-pr' })!.label).toBe('No PR')
const notInstalled = chipText({ availability: 'not-installed' })!
expect(notInstalled.label).toBe('gh not installed')
expect(notInstalled.title).toContain('cli.github.com')
expect(chipText({ availability: 'unauthenticated' })!.label).toBe('gh auth login')
expect(chipText({ availability: 'error' })!.label).toBe('PR status unavailable')
})
it('returns null for a disabled status (chip hidden)', () => {
expect(chipText({ availability: 'disabled' })).toBeNull()
})
})
// ── renderPrChip ──────────────────────────────────────────────────────────────
describe('renderPrChip', () => {
it('renders a chip element with the label and state class', () => {
const chip = renderPrChip(okStatus())
expect(chip.className).toContain('proj-pr-chip')
expect(chip.className).toContain('proj-pr-open')
expect(chip.querySelector('.proj-pr-label')?.textContent).toBe('PR #12 ✓ 5/5')
})
it('renders a hidden chip for a disabled status', () => {
const chip = renderPrChip({ availability: 'disabled' })
expect(chip.style.display).toBe('none')
expect(chip.querySelector('.proj-pr-label')).toBeNull()
})
it('renders an attacker PR title as LITERAL text — no element injection (SEC-H4)', () => {
const evil = '<img src=x onerror=alert(1)>'
const chip = renderPrChip(okStatus({ title: evil }))
expect(chip.textContent).toContain(evil)
expect(chip.querySelector('img')).toBeNull()
// The tooltip attribute is also inert (attributes are never HTML-parsed).
expect(chip.title).toBe(evil)
})
})
// ── fetchPrStatus ─────────────────────────────────────────────────────────────
describe('fetchPrStatus', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('normalizes a 200 JSON response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
)
const s = await fetchPrStatus('/p/repo')
expect(s?.availability).toBe('ok')
expect(s?.number).toBe(12)
})
it('returns null on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
expect(await fetchPrStatus('/p/repo')).toBeNull()
})
it('returns null when fetch rejects (never throws)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network down')
}),
)
expect(await fetchPrStatus('/p/repo')).toBeNull()
})
})
// ── mountPrChip ───────────────────────────────────────────────────────────────
describe('mountPrChip', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
let host: HTMLElement
beforeEach(() => {
host = document.createElement('span')
document.body.append(host)
})
it('shows a loading placeholder, then swaps in the resolved chip', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
)
const handle = mountPrChip(host, '/p/repo')
expect(host.querySelector('.proj-pr-loading')).not.toBeNull()
await new Promise((r) => setTimeout(r, 0))
expect(host.querySelector('.proj-pr-loading')).toBeNull()
expect(host.querySelector('.proj-pr-open')).not.toBeNull()
handle.destroy()
})
it('degrades to an error chip when fetch rejects (no throw)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('boom')
}),
)
mountPrChip(host, '/p/repo')
await new Promise((r) => setTimeout(r, 0))
expect(host.querySelector('.proj-pr-unavailable')).not.toBeNull()
expect(host.textContent).toContain('PR status unavailable')
})
it('destroy() removes the chip node', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
)
const handle = mountPrChip(host, '/p/repo')
handle.destroy()
await new Promise((r) => setTimeout(r, 0))
expect(host.childNodes.length).toBe(0)
})
})

151
test/git-log.test.ts Normal file
View File

@@ -0,0 +1,151 @@
// @vitest-environment jsdom
/**
* test/git-log.test.ts (W3 quick-wins d) — recent-commit list (public/git-log.ts).
*
* Pure normalize/render + the mountGitLog wiring with a mocked fetch. Security:
* commit subjects are attacker-influenced, so a subject containing an <img
* onerror> payload must appear verbatim as text (no HTML injection). A fetch
* failure degrades to an inert message (best-effort, never throws).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { GitLogResult } from '../src/types.js'
import {
normalizeGitLog,
renderGitLog,
fetchGitLog,
mountGitLog,
} from '../public/git-log.js'
function makeLog(over: Partial<GitLogResult> = {}): GitLogResult {
return {
commits: [
{ hash: 'abc1234', at: Date.now() - 3600_000, subject: 'first commit' },
{ hash: 'def5678', at: Date.now() - 7200_000, subject: 'second commit' },
],
truncated: false,
...over,
}
}
function mockFetch(body: unknown, ok = true): ReturnType<typeof vi.fn> {
const fn = vi.fn(async () => {
if (body === null) throw new Error('network down')
return { ok, json: async () => body } as Response
})
vi.stubGlobal('fetch', fn)
return fn
}
afterEach(() => {
vi.unstubAllGlobals()
})
// ── normalizeGitLog ───────────────────────────────────────────────────────────
describe('normalizeGitLog', () => {
it('returns null for a non-object / missing commits array', () => {
expect(normalizeGitLog(null)).toBeNull()
expect(normalizeGitLog({ truncated: true })).toBeNull()
})
it('keeps well-formed commits and drops malformed ones', () => {
const out = normalizeGitLog({
commits: [
{ hash: 'h1', at: 1000, subject: 'ok' },
{ hash: 'h2', at: 'nope', subject: 'bad-at' },
{ hash: 5, at: 1, subject: 'bad-hash' },
{ at: 1, subject: 'missing-hash' },
],
truncated: true,
})
expect(out?.commits.map((c) => c.hash)).toEqual(['h1'])
expect(out?.truncated).toBe(true)
})
})
// ── renderGitLog ──────────────────────────────────────────────────────────────
describe('renderGitLog', () => {
it('renders one row per commit with hash / time / subject', () => {
const host = document.createElement('div')
renderGitLog(host, makeLog())
const rows = host.querySelectorAll('.proj-commit-row')
expect(rows).toHaveLength(2)
expect(rows[0]?.querySelector('.proj-commit-hash')?.textContent).toBe('abc1234')
expect(rows[0]?.querySelector('.proj-commit-subject')?.textContent).toBe('first commit')
})
it('renders an empty note when there are no commits', () => {
const host = document.createElement('div')
renderGitLog(host, makeLog({ commits: [] }))
expect(host.querySelector('.proj-empty')).not.toBeNull()
expect(host.querySelector('.proj-commit-row')).toBeNull()
})
it('shows a truncation note when truncated', () => {
const host = document.createElement('div')
renderGitLog(host, makeLog({ truncated: true }))
expect(host.querySelector('.proj-commit-more')).not.toBeNull()
})
it('renders a subject with an HTML payload as inert text (SEC-H5)', () => {
const host = document.createElement('div')
const xss = '<img src=x onerror=alert(1)>'
renderGitLog(host, makeLog({ commits: [{ hash: 'h1', at: Date.now(), subject: xss }] }))
const subj = host.querySelector('.proj-commit-subject')
expect(subj?.textContent).toBe(xss) // verbatim text
expect(host.querySelectorAll('img').length).toBe(0) // no element injected
})
})
// ── fetchGitLog / mountGitLog ─────────────────────────────────────────────────
describe('fetchGitLog', () => {
it('requests /projects/log with the encoded repo path', async () => {
const fetchFn = mockFetch(makeLog())
await fetchGitLog('/home/u/my repo')
expect(fetchFn).toHaveBeenCalledWith('/projects/log?path=%2Fhome%2Fu%2Fmy%20repo')
})
it('returns null on a fetch failure (best-effort)', async () => {
mockFetch(null)
expect(await fetchGitLog('/x')).toBeNull()
})
it('returns null on a non-ok response', async () => {
mockFetch({}, false)
expect(await fetchGitLog('/x')).toBeNull()
})
})
describe('mountGitLog', () => {
it('shows a loading placeholder, then swaps in commit rows', async () => {
mockFetch(makeLog())
const host = document.createElement('div')
mountGitLog(host, '/repo')
expect(host.querySelector('.proj-commitlog-loading')).not.toBeNull()
await vi.waitFor(() => {
expect(host.querySelector('.proj-commit-row')).not.toBeNull()
})
})
it('degrades to an inert message on a fetch failure', async () => {
mockFetch(null)
const host = document.createElement('div')
mountGitLog(host, '/repo')
await vi.waitFor(() => {
expect(host.querySelector('.proj-empty')).not.toBeNull()
})
})
it('destroy() clears the container and cancels the swap', async () => {
mockFetch(makeLog())
const host = document.createElement('div')
const handle = mountGitLog(host, '/repo')
handle.destroy()
// give the async swap a chance — it must not repopulate after destroy
await new Promise((r) => setTimeout(r, 0))
expect(host.querySelector('.proj-commit-row')).toBeNull()
})
})

View File

@@ -0,0 +1,189 @@
/**
* test/http/approval-preview.test.ts (W1) — deriveApprovalPreview unit tests.
*
* Pure, never-throws derivation of a bounded, sanitized command/diff preview
* from an untrusted hook tool_input. Node env (no DOM).
*/
import { describe, it, expect } from 'vitest'
import {
deriveApprovalPreview,
PREVIEW_MAX_LINES,
PREVIEW_MAX_BYTES,
} from '../../src/http/approval-preview.js'
import type { ApprovalPreview, DiffFile } from '../../src/types.js'
/** Narrow a preview to its 'diff' variant (throws in-test if wrong shape). */
function asDiff(p: ApprovalPreview | null): DiffFile {
expect(p).not.toBeNull()
expect(p!.kind).toBe('diff')
return (p as { kind: 'diff'; file: DiffFile }).file
}
describe('deriveApprovalPreview — Bash → command', () => {
it('extracts the command string', () => {
const p = deriveApprovalPreview('Bash', { command: 'ls -la', description: 'x' })
expect(p).toEqual({ kind: 'command', text: 'ls -la' })
})
it('preserves newlines in a multi-line command', () => {
const p = deriveApprovalPreview('Bash', { command: 'a\nb' })
expect(p).toMatchObject({ kind: 'command' })
expect((p as { text: string }).text).toContain('\n')
expect((p as { text: string }).text).toBe('a\nb')
})
it('strips ANSI/control injection (ESC, BEL) from the command', () => {
const p = deriveApprovalPreview('Bash', { command: '\x1b[31mrm\x1b[0m\x07' })
const text = (p as { text: string }).text
expect(text).not.toContain('\x1b')
expect(text).not.toContain('\x07')
expect(text).toBe('[31mrm[0m') // control bytes gone, printable payload remains
})
it('returns null when command is missing / not a string', () => {
expect(deriveApprovalPreview('Bash', { description: 'x' })).toBeNull()
expect(deriveApprovalPreview('Bash', { command: 42 })).toBeNull()
})
})
describe('deriveApprovalPreview — Edit → diff', () => {
it('builds one hunk with removed + added lines and correct counts', () => {
const file = asDiff(
deriveApprovalPreview('Edit', {
file_path: '/p/f.ts',
old_string: 'a\nb',
new_string: 'c',
}),
)
expect(file.newPath).toBe('/p/f.ts')
expect(file.removed).toBe(2)
expect(file.added).toBe(1)
expect(file.hunks).toHaveLength(1)
const kinds = file.hunks[0]!.lines.map((l) => `${l.kind}:${l.text}`)
expect(kinds).toEqual(['removed:a', 'removed:b', 'added:c'])
})
it('sanitizes the file path (strips control chars)', () => {
const file = asDiff(
deriveApprovalPreview('Edit', {
file_path: '/p/\x1b[2Jevil.ts',
old_string: 'x',
new_string: 'y',
}),
)
expect(file.newPath).not.toContain('\x1b')
expect(file.newPath).toBe('/p/[2Jevil.ts')
})
it('returns null for an Edit missing old_string', () => {
expect(deriveApprovalPreview('Edit', { file_path: '/p/f', new_string: 'c' })).toBeNull()
})
})
describe('deriveApprovalPreview — Write → all-added diff', () => {
it('emits an all-added hunk with removed===0', () => {
const file = asDiff(deriveApprovalPreview('Write', { file_path: '/p/f', content: 'x\ny' }))
expect(file.removed).toBe(0)
expect(file.added).toBe(2)
expect(file.status).toBe('added')
expect(file.hunks[0]!.lines.map((l) => l.kind)).toEqual(['added', 'added'])
})
it('returns null when content is not a string', () => {
expect(deriveApprovalPreview('Write', { file_path: '/p/f' })).toBeNull()
})
})
describe('deriveApprovalPreview — MultiEdit → one hunk per edit', () => {
it('produces a hunk for each edit', () => {
const file = asDiff(
deriveApprovalPreview('MultiEdit', {
file_path: '/p/f',
edits: [
{ old_string: 'a', new_string: 'b' },
{ old_string: 'c', new_string: 'd' },
],
}),
)
expect(file.hunks).toHaveLength(2)
expect(file.removed).toBe(2)
expect(file.added).toBe(2)
})
it('drops malformed edit entries; null when all are invalid', () => {
expect(
deriveApprovalPreview('MultiEdit', { file_path: '/p/f', edits: [{ old_string: 'a' }] }),
).toBeNull()
const file = asDiff(
deriveApprovalPreview('MultiEdit', {
file_path: '/p/f',
edits: [{ old_string: 'a' }, { old_string: 'x', new_string: 'y' }],
}),
)
expect(file.hunks).toHaveLength(1)
})
it('returns null when edits is not an array', () => {
expect(deriveApprovalPreview('MultiEdit', { file_path: '/p/f', edits: 'nope' })).toBeNull()
})
})
describe('deriveApprovalPreview — NotebookEdit', () => {
it('previews new_source as an all-added diff', () => {
const file = asDiff(
deriveApprovalPreview('NotebookEdit', { notebook_path: '/n.ipynb', new_source: 'print(1)' }),
)
expect(file.newPath).toBe('/n.ipynb')
expect(file.added).toBe(1)
expect(file.removed).toBe(0)
})
})
describe('deriveApprovalPreview — truncation (DoS containment)', () => {
it('caps line count and flags truncated for a huge command', () => {
const command = Array.from({ length: PREVIEW_MAX_LINES + 20 }, (_, i) => `line${i}`).join('\n')
const p = deriveApprovalPreview('Bash', { command })
expect(p).toMatchObject({ kind: 'command', truncated: true })
const text = (p as { text: string }).text
expect(text.split('\n').length).toBeLessThanOrEqual(PREVIEW_MAX_LINES)
})
it('caps total bytes for a huge single-line blob', () => {
const command = 'x'.repeat(PREVIEW_MAX_BYTES * 4)
const p = deriveApprovalPreview('Bash', { command })
expect(p).toMatchObject({ truncated: true })
const text = (p as { text: string }).text
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(PREVIEW_MAX_BYTES)
})
it('caps a giant Write and keeps the serialized diff within the byte cap', () => {
const content = Array.from({ length: 500 }, (_, i) => `row-${i}-`.repeat(50)).join('\n')
const p = deriveApprovalPreview('Write', { file_path: '/p/f', content })
expect(p).toMatchObject({ kind: 'diff', truncated: true })
const file = asDiff(p)
const emitted = file.hunks.flatMap((h) => h.lines)
expect(emitted.length).toBeLessThanOrEqual(PREVIEW_MAX_LINES)
const bytes = emitted.reduce((n, l) => n + Buffer.byteLength(l.text, 'utf8'), 0)
expect(bytes).toBeLessThanOrEqual(PREVIEW_MAX_BYTES)
})
})
describe('deriveApprovalPreview — unknown tools & malformed input (never throws)', () => {
it('returns null for a non-previewable tool', () => {
expect(deriveApprovalPreview('WebFetch', { url: 'http://x' })).toBeNull()
expect(deriveApprovalPreview('ExitPlanMode', {})).toBeNull()
})
it('returns null for undefined tool name', () => {
expect(deriveApprovalPreview(undefined, { command: 'ls' })).toBeNull()
})
it('returns null (never throws) for null / array / number / string tool_input', () => {
expect(() => deriveApprovalPreview('Bash', null)).not.toThrow()
expect(deriveApprovalPreview('Bash', null)).toBeNull()
expect(deriveApprovalPreview('Bash', [1, 2])).toBeNull()
expect(deriveApprovalPreview('Edit', 42)).toBeNull()
expect(deriveApprovalPreview('Bash', 'ls -la')).toBeNull()
})
})

View File

@@ -18,6 +18,7 @@ import {
parseNumstat,
parseUnifiedDiff,
getDiff,
isPlausibleRev,
type NumstatEntry,
type GetDiffOptions,
} from '../../src/http/diff.js'
@@ -30,6 +31,44 @@ const LIMITS: GetDiffOptions['cfg'] = {
diffMaxFiles: 300,
}
// ── isPlausibleRev (FR-B1.9 syntactic allow-list) ────────────────────────────
describe('isPlausibleRev', () => {
it('accepts ordinary commit-ish forms', () => {
for (const ok of ['main', 'feature/x', 'HEAD~3', 'v1.2.0', 'main^', 'HEAD@{1}',
'0123456789abcdef0123456789abcdef01234567']) {
expect(isPlausibleRev(ok)).toBe(true)
}
})
it('rejects the empty string', () => {
expect(isPlausibleRev('')).toBe(false)
})
it('rejects an over-long revision (>250 chars)', () => {
expect(isPlausibleRev('a'.repeat(300))).toBe(false)
expect(isPlausibleRev('a'.repeat(250))).toBe(true) // boundary
})
it('rejects flag/option injection (leading hyphen)', () => {
expect(isPlausibleRev('-rf')).toBe(false)
expect(isPlausibleRev('--output=/etc/passwd')).toBe(false)
expect(isPlausibleRev('--upload-pack=touch /tmp/pwn')).toBe(false)
})
it('rejects range syntax (.. and ...)', () => {
expect(isPlausibleRev('a..b')).toBe(false)
expect(isPlausibleRev('a...b')).toBe(false)
})
it('rejects whitespace and shell metacharacters', () => {
for (const bad of ['x y', 'a\tb', '`id`', '$(x)', 'a;b', 'a|b', 'a&b', 'a>b', "a'b", 'a"b',
'main; rm -rf /', 'a\x00b']) {
expect(isPlausibleRev(bad)).toBe(false)
}
})
})
// ── parseNumstat ──────────────────────────────────────────────────────────────
describe('parseNumstat', () => {
@@ -346,3 +385,74 @@ describe('getDiff (real git repo)', () => {
expect(result.truncated).toBe(false)
})
})
// ── getDiff against a base revision (FR-B1.9, real git repo) ──────────────────
describe('getDiff against a base revision (FR-B1.9)', () => {
let repo: string
beforeAll(async () => {
repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-basediff-'))
await git(repo, 'init', '-q', '-b', 'main')
await git(repo, 'config', 'user.email', 'test@example.com')
await git(repo, 'config', 'user.name', 'Test')
await git(repo, 'config', 'commit.gpgsign', 'false')
await fs.writeFile(path.join(repo, 'app.txt'), 'base one\nbase two\n')
await git(repo, 'add', '.')
await git(repo, 'commit', '-q', '-m', 'base commit on main')
// Branch off and add a feature-only change (committed on `feature`, not main).
await git(repo, 'checkout', '-q', '-b', 'feature')
await fs.writeFile(path.join(repo, 'feature.txt'), 'brand new feature file\n')
await git(repo, 'add', '.')
await git(repo, 'commit', '-q', '-m', 'feature work')
})
afterAll(async () => {
await fs.rm(repo, { recursive: true, force: true })
})
it('diffs the whole branch against main (three-dot), echoing base', async () => {
const result = await getDiff(repo, { staged: false, base: 'main', cfg: LIMITS })
expect(result.base).toBe('main')
expect(result.staged).toBe(false)
expect(result.truncated).toBe(false)
const feat = result.files.find((f) => f.newPath === 'feature.txt')
expect(feat).toBeDefined()
expect(feat?.status).toBe('added')
// numstat-consistent counts for the committed feature file
expect(feat?.added).toBe(1)
expect(feat?.removed).toBe(0)
// A base diff never lists untracked entries.
expect(result.files.every((f) => f.status !== 'untracked')).toBe(true)
})
it('does NOT include working-tree-only untracked files in a base diff', async () => {
await fs.writeFile(path.join(repo, 'scratch.txt'), 'not committed\n')
const result = await getDiff(repo, { staged: false, base: 'main', cfg: LIMITS })
expect(result.files.find((f) => f.newPath === 'scratch.txt')).toBeUndefined()
await fs.rm(path.join(repo, 'scratch.txt'))
})
it('returns an empty diff when HEAD equals the base (no branch changes)', async () => {
const result = await getDiff(repo, { staged: false, base: 'feature', cfg: LIMITS })
expect(result.base).toBe('feature')
expect(result.files).toEqual([])
expect(result.truncated).toBe(false)
})
it('returns an empty result for an unknown/unresolvable base (rev-parse miss)', async () => {
const result = await getDiff(repo, { staged: false, base: 'no-such-branch', cfg: LIMITS })
expect(result.base).toBe('no-such-branch')
expect(result.files).toEqual([])
expect(result.truncated).toBe(false)
})
it('never runs a diff for an implausible base (defense-in-depth, no throw)', async () => {
// The route rejects this with 400 before getDiff; getDiff must still be safe.
for (const bad of ['-x', '--output=/tmp/x', 'main; rm -rf /', 'a..b']) {
const result = await getDiff(repo, { staged: false, base: bad, cfg: LIMITS })
expect(result.files).toEqual([])
expect(result.base).toBe(bad)
}
})
})

114
test/http/digest.test.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* test/http/digest.test.ts (W3 quick-wins c) — buildDigest read-side aggregate.
*
* Pure over an injected LiveSessionInfo[] (mirrors buildProjects injection):
* counts finished (idle & lastOutputAt > since), needsInput (waiting), stuck,
* working; sums telemetry.costUsd; empty → zeroes; future `since` → 0 finished;
* bad `since` clamps to 0.
*/
import { describe, it, expect } from 'vitest'
import type { LiveSessionInfo, ClaudeStatus } from '../../src/types.js'
import { buildDigest, clampSince } from '../../src/http/digest.js'
function live(over: Partial<LiveSessionInfo> & { id: string; status: ClaudeStatus }): LiveSessionInfo {
return {
createdAt: 1000,
clientCount: 0,
exited: false,
cwd: null,
cols: 80,
rows: 24,
...over,
}
}
// ── clampSince ────────────────────────────────────────────────────────────────
describe('clampSince', () => {
it('passes through a finite non-negative number', () => {
expect(clampSince(1234)).toBe(1234)
expect(clampSince(0)).toBe(0)
})
it('clamps NaN / negative / non-numeric / undefined to 0', () => {
expect(clampSince(NaN)).toBe(0)
expect(clampSince(-5)).toBe(0)
expect(clampSince('abc')).toBe(0)
expect(clampSince(undefined)).toBe(0)
})
it('parses a numeric string', () => {
expect(clampSince('42')).toBe(42)
})
})
// ── buildDigest ───────────────────────────────────────────────────────────────
describe('buildDigest', () => {
it('returns an all-zero aggregate for an empty list', () => {
const d = buildDigest([], 0)
expect(d).toMatchObject({
since: 0,
total: 0,
finished: 0,
needsInput: 0,
stuck: 0,
working: 0,
totalCostUsd: 0,
sessions: [],
})
expect(typeof d.generatedAt).toBe('number')
})
it('counts finished (idle + output after since), needsInput, stuck, working', () => {
const sessions: LiveSessionInfo[] = [
live({ id: 'a', status: 'idle', lastOutputAt: 5000 }), // finished (5000 > 100)
live({ id: 'b', status: 'waiting' }), // needsInput
live({ id: 'c', status: 'stuck' }), // stuck
live({ id: 'd', status: 'working' }), // working
live({ id: 'e', status: 'idle', lastOutputAt: 50 }), // idle but stale (50 < 100) → not finished
]
const d = buildDigest(sessions, 100)
expect(d.total).toBe(5)
expect(d.finished).toBe(1)
expect(d.needsInput).toBe(1)
expect(d.stuck).toBe(1)
expect(d.working).toBe(1)
})
it('does not count an idle session with no lastOutputAt as finished', () => {
const d = buildDigest([live({ id: 'a', status: 'idle' })], 0)
expect(d.finished).toBe(0)
})
it('treats a future `since` as "nothing new" (0 finished)', () => {
const d = buildDigest([live({ id: 'a', status: 'idle', lastOutputAt: 1000 })], 999_999_999_999)
expect(d.finished).toBe(0)
})
it('sums telemetry.costUsd across sessions', () => {
const sessions: LiveSessionInfo[] = [
live({ id: 'a', status: 'idle', telemetry: { at: 1, costUsd: 1.5 } }),
live({ id: 'b', status: 'working', telemetry: { at: 1, costUsd: 2.25 } }),
live({ id: 'c', status: 'working' }), // no telemetry → contributes 0
]
const d = buildDigest(sessions, 0)
expect(d.totalCostUsd).toBeCloseTo(3.75)
})
it('projects per-session flags + title (last cwd segment)', () => {
const d = buildDigest([live({ id: 'a', status: 'waiting', cwd: '/home/u/my-repo' })], 0)
const row = d.sessions[0]
expect(row?.id).toBe('a')
expect(row?.title).toBe('my-repo')
expect(row?.needsInput).toBe(true)
expect(row?.finished).toBe(false)
expect(row?.stuck).toBe(false)
})
it('clamps a bad `since` to 0', () => {
const d = buildDigest([], NaN as unknown as number)
expect(d.since).toBe(0)
})
})

285
test/http/gh.test.ts Normal file
View File

@@ -0,0 +1,285 @@
/**
* test/http/gh.test.ts (W3 PR + CI status chip) — pure parsers + getPrStatus.
*
* Two layers (mirrors diff.test.ts):
* 1. Pure, deterministic: summarizeChecks / parsePrView / classifyGhFailure fed
* canned gh output — the bulk of gh.ts logic, fully covered without spawning
* gh. Never throws; <script> in a PR title survives verbatim.
* 2. getPrStatus with an INJECTED fake runner (no real gh) against a throwaway
* .git/HEAD in os.tmpdir — cache TTL, in-flight dedupe, branch-keyed busting,
* and the GH_ENABLED=false short-circuit.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import {
summarizeChecks,
parsePrView,
classifyGhFailure,
getPrStatus,
_clearPrCache,
type GhExecResult,
type GhOptions,
type GhRunner,
} from '../../src/http/gh.js'
const CFG: GhOptions = {
ghEnabled: true,
ghTimeoutMs: 8000,
projectScanTtlMs: 10_000,
diffMaxBytes: 2 * 1024 * 1024,
}
// ── summarizeChecks ───────────────────────────────────────────────────────────
describe('summarizeChecks', () => {
it('counts a completed CheckRun SUCCESS as passing', () => {
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' }])
expect(s).toEqual({ total: 1, passing: 1, failing: 0, pending: 0 })
})
it('counts failing CheckRun conclusions as failing', () => {
for (const conclusion of ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']) {
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion }])
expect(s.failing).toBe(1)
expect(s.total).toBe(1)
}
})
it('counts an in-progress / queued CheckRun (null conclusion) as pending', () => {
for (const status of ['IN_PROGRESS', 'QUEUED']) {
const s = summarizeChecks([{ __typename: 'CheckRun', status, conclusion: null }])
expect(s.pending).toBe(1)
expect(s.total).toBe(1)
}
})
it('treats NEUTRAL and SKIPPED conclusions as passing (do not block)', () => {
for (const conclusion of ['NEUTRAL', 'SKIPPED']) {
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion }])
expect(s.passing).toBe(1)
}
})
it('buckets StatusContext by state', () => {
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'SUCCESS' }]).passing).toBe(1)
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'PENDING' }]).pending).toBe(1)
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'FAILURE' }]).failing).toBe(1)
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'ERROR' }]).failing).toBe(1)
})
it('returns all-zero for an empty / undefined / non-array rollup', () => {
const zero = { total: 0, passing: 0, failing: 0, pending: 0 }
expect(summarizeChecks([])).toEqual(zero)
expect(summarizeChecks(undefined)).toEqual(zero)
expect(summarizeChecks(null)).toEqual(zero)
expect(summarizeChecks('nope')).toEqual(zero)
})
it('counts an unknown-shape item in total, treated as pending', () => {
const s = summarizeChecks([{ __typename: 'Mystery', foo: 1 }, 42, null])
expect(s.total).toBe(3)
expect(s.pending).toBe(3)
})
it('keeps total === passing + failing + pending across a mixed rollup', () => {
const s = summarizeChecks([
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' },
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null },
{ __typename: 'StatusContext', state: 'SUCCESS' },
{ __typename: 'StatusContext', state: 'PENDING' },
{ weird: true },
])
expect(s.total).toBe(6)
expect(s.passing + s.failing + s.pending).toBe(s.total)
expect(s).toEqual({ total: 6, passing: 2, failing: 1, pending: 3 })
})
it('buckets a bare CheckRun conclusion (no status field) by conclusion', () => {
expect(summarizeChecks([{ conclusion: 'FAILURE' }]).failing).toBe(1)
expect(summarizeChecks([{ conclusion: 'SUCCESS' }]).passing).toBe(1)
})
})
// ── parsePrView ───────────────────────────────────────────────────────────────
const okJson = JSON.stringify({
number: 12,
state: 'OPEN',
title: 'Add the thing',
url: 'https://github.com/o/r/pull/12',
isDraft: false,
mergeable: 'MERGEABLE',
headRefName: 'feature/x',
baseRefName: 'main',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
],
})
describe('parsePrView', () => {
it('maps a valid PR JSON to availability:ok with lower-cased state/mergeable', () => {
const s = parsePrView(okJson)
expect(s.availability).toBe('ok')
expect(s.number).toBe(12)
expect(s.title).toBe('Add the thing')
expect(s.url).toBe('https://github.com/o/r/pull/12')
expect(s.state).toBe('open')
expect(s.isDraft).toBe(false)
expect(s.mergeable).toBe('mergeable')
expect(s.headRefName).toBe('feature/x')
expect(s.baseRefName).toBe('main')
expect(s.checks).toEqual({ total: 2, passing: 2, failing: 0, pending: 0 })
})
it('keeps availability:ok for a draft PR and lower-cases UNKNOWN mergeable', () => {
const s = parsePrView(
JSON.stringify({ number: 3, state: 'OPEN', isDraft: true, mergeable: 'UNKNOWN' }),
)
expect(s.availability).toBe('ok')
expect(s.isDraft).toBe(true)
expect(s.mergeable).toBe('unknown')
})
it('degrades malformed / non-object JSON to availability:error (never throws)', () => {
expect(parsePrView('not json {').availability).toBe('error')
expect(parsePrView('42').availability).toBe('error')
expect(parsePrView('null').availability).toBe('error')
expect(parsePrView('[1,2,3]').availability).toBe('error')
expect(parsePrView('').availability).toBe('error')
})
it('carries a <script> PR title VERBATIM (no parsing-side mangling — SEC-H4)', () => {
const evil = '<script>alert(1)</script>'
const s = parsePrView(JSON.stringify({ number: 1, state: 'OPEN', title: evil }))
expect(s.availability).toBe('ok')
expect(s.title).toBe(evil)
})
})
// ── classifyGhFailure ─────────────────────────────────────────────────────────
describe('classifyGhFailure', () => {
it('maps a spawn ENOENT to not-installed', () => {
expect(classifyGhFailure({ code: 'ENOENT', stderr: '' })).toBe('not-installed')
})
it('maps auth-related stderr to unauthenticated', () => {
for (const stderr of [
'run gh auth login to authenticate',
'you are not logged into any GitHub hosts',
'authentication required',
'HTTP 401: Bad credentials',
]) {
expect(classifyGhFailure({ code: 1, stderr })).toBe('unauthenticated')
}
})
it('maps no-PR / no-remote stderr to no-pr', () => {
for (const stderr of [
'no pull requests found for branch "feature/x"',
'no default remote repository has been set',
'no git remotes found',
]) {
expect(classifyGhFailure({ code: 1, stderr })).toBe('no-pr')
}
})
it('maps any other non-zero exit (incl. maxBuffer overflow) to error', () => {
expect(classifyGhFailure({ code: 1, stderr: 'something exploded' })).toBe('error')
expect(classifyGhFailure({ code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', stderr: '' })).toBe('error')
expect(classifyGhFailure({ code: undefined, stderr: '' })).toBe('error')
})
})
// ── getPrStatus (cache / dedupe / branch-key / disabled) ──────────────────────
/** A runner that resolves canned exec results and records call count. */
function fakeRunner(result: GhExecResult): { runner: GhRunner; calls: () => number } {
const spy = vi.fn<GhRunner>(async () => result)
return { runner: spy, calls: () => spy.mock.calls.length }
}
async function writeHead(repoPath: string, branch: string): Promise<void> {
await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
}
describe('getPrStatus', () => {
let repoPath: string
beforeEach(async () => {
_clearPrCache()
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gh-test-'))
await fs.mkdir(path.join(repoPath, '.git'), { recursive: true })
await writeHead(repoPath, 'main')
})
afterEach(async () => {
_clearPrCache()
await fs.rm(repoPath, { recursive: true, force: true })
})
it('resolves availability:disabled WITHOUT invoking the runner when ghEnabled:false', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
const s = await getPrStatus(repoPath, { ...CFG, ghEnabled: false }, runner)
expect(s).toEqual({ availability: 'disabled' })
expect(calls()).toBe(0)
})
it('returns parsed status and caches it (second call does not re-run gh)', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
const first = await getPrStatus(repoPath, CFG, runner)
expect(first.availability).toBe('ok')
expect(first.number).toBe(12)
await getPrStatus(repoPath, CFG, runner)
expect(calls()).toBe(1) // served from cache
})
it('shares one in-flight run across two concurrent calls for the same path', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
const [a, b] = await Promise.all([
getPrStatus(repoPath, CFG, runner),
getPrStatus(repoPath, CFG, runner),
])
expect(a.availability).toBe('ok')
expect(b.availability).toBe('ok')
expect(calls()).toBe(1)
})
it('re-runs gh after _clearPrCache', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
await getPrStatus(repoPath, CFG, runner)
_clearPrCache()
await getPrStatus(repoPath, CFG, runner)
expect(calls()).toBe(2)
})
it('busts the cache when the branch (.git/HEAD) changes', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
await getPrStatus(repoPath, CFG, runner)
await writeHead(repoPath, 'other-branch')
await getPrStatus(repoPath, CFG, runner)
expect(calls()).toBe(2) // key includes the branch
})
it('degrades to no-pr when gh exits non-zero with a no-PR stderr (never throws)', async () => {
const { runner } = fakeRunner({
ok: false,
stdout: '',
stderr: 'no pull requests found for branch "main"',
code: 1,
})
const s = await getPrStatus(repoPath, CFG, runner)
expect(s).toEqual({ availability: 'no-pr' })
})
it('degrades to not-installed on a spawn ENOENT', async () => {
const { runner } = fakeRunner({ ok: false, stdout: '', stderr: '', code: 'ENOENT' })
const s = await getPrStatus(repoPath, CFG, runner)
expect(s).toEqual({ availability: 'not-installed' })
})
})

168
test/http/git-log.test.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* test/http/git-log.test.ts (W3 quick-wins d) — recent-commit log parsing + getGitLog.
*
* Two layers:
* 1. parseGitLog fed canned NUL-record/US-field output — the deterministic core
* (empty → []; malformed record skipped; subject with tabs/newlines intact;
* subject truncated at the cap; truncated flag when records === max).
* 2. getGitLog against a REAL throwaway git repo in os.tmpdir (newest-first,
* n-clamping + truncated flag). Mirrors test/http/diff.test.ts.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
parseGitLog,
getGitLog,
clampLogCount,
GIT_LOG_MAX,
GIT_LOG_DEFAULT,
} from '../../src/http/git-log.js'
const execFileAsync = promisify(execFile)
const US = '\x1f'
const NUL = '\x00'
/** Build one NUL-terminated record: hash US ct US subject. */
function rec(hash: string, ct: number, subject: string): string {
return `${hash}${US}${ct}${US}${subject}${NUL}`
}
// ── clampLogCount ─────────────────────────────────────────────────────────────
describe('clampLogCount', () => {
it('defaults to GIT_LOG_DEFAULT for missing / non-numeric', () => {
expect(clampLogCount(undefined)).toBe(GIT_LOG_DEFAULT)
expect(clampLogCount('abc')).toBe(GIT_LOG_DEFAULT)
expect(clampLogCount(NaN)).toBe(GIT_LOG_DEFAULT)
})
it('clamps to [1, GIT_LOG_MAX]', () => {
expect(clampLogCount(0)).toBe(1)
expect(clampLogCount(-5)).toBe(1)
expect(clampLogCount(999)).toBe(GIT_LOG_MAX)
expect(clampLogCount(GIT_LOG_MAX)).toBe(GIT_LOG_MAX)
})
it('parses a numeric string and floors it', () => {
expect(clampLogCount('3')).toBe(3)
expect(clampLogCount(3.9)).toBe(3)
})
})
// ── parseGitLog (pure) ────────────────────────────────────────────────────────
describe('parseGitLog', () => {
it('returns [] for empty stdout', () => {
expect(parseGitLog('', 20)).toEqual({ commits: [], truncated: false })
})
it('parses one record into hash / at(ms) / subject', () => {
const out = parseGitLog(rec('abc1234', 1700000000, 'first commit'), 20)
expect(out.commits).toHaveLength(1)
expect(out.commits[0]).toEqual({
hash: 'abc1234',
at: 1700000000 * 1000,
subject: 'first commit',
})
expect(out.truncated).toBe(false)
})
it('preserves subjects containing tabs and newlines (US/NUL delimiters)', () => {
const nasty = 'fix:\ttabbed\nand newlined'
const out = parseGitLog(rec('h1', 1700000000, nasty), 20)
expect(out.commits[0]?.subject).toBe(nasty)
})
it('skips a malformed record missing a field', () => {
const good = rec('h1', 1700000000, 'ok')
const bad = `h2${US}onlytwo${NUL}` // only 2 fields
const out = parseGitLog(good + bad, 20)
expect(out.commits.map((c) => c.hash)).toEqual(['h1'])
})
it('skips a record with a non-numeric timestamp', () => {
const out = parseGitLog(rec('h1', NaN as unknown as number, 'x') + rec('h2', 1700000001, 'y'), 20)
expect(out.commits.map((c) => c.hash)).toEqual(['h2'])
})
it('truncates an over-long subject at the cap', () => {
const long = 'x'.repeat(1000)
const out = parseGitLog(rec('h1', 1700000000, long), 20)
expect(out.commits[0]?.subject.length).toBe(500)
})
it('sets truncated when records === max (there may be more)', () => {
const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b') + rec('h3', 3, 'c')
const out = parseGitLog(stdout, 3)
expect(out.commits).toHaveLength(3)
expect(out.truncated).toBe(true)
})
it('does NOT set truncated when fewer records than max', () => {
const stdout = rec('h1', 1, 'a') + rec('h2', 2, 'b')
const out = parseGitLog(stdout, 3)
expect(out.truncated).toBe(false)
})
})
// ── getGitLog (integration against a real git repo) ──────────────────────────
async function git(cwd: string, ...args: string[]): Promise<void> {
await execFileAsync('git', args, { cwd })
}
describe('getGitLog (real git repo)', () => {
let repo: string
beforeAll(async () => {
repo = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitlog-'))
await git(repo, 'init', '-q', '-b', 'main')
await git(repo, 'config', 'user.email', 'test@example.com')
await git(repo, 'config', 'user.name', 'Test')
await git(repo, 'config', 'commit.gpgsign', 'false')
for (const [file, msg] of [
['a.txt', 'first'],
['b.txt', 'second'],
['c.txt', 'third'],
]) {
await fs.writeFile(path.join(repo, file), `${file}\n`)
await git(repo, 'add', '.')
await git(repo, 'commit', '-q', '-m', msg)
}
})
afterAll(async () => {
await fs.rm(repo, { recursive: true, force: true })
})
it('returns 3 commits newest-first', async () => {
const out = await getGitLog(repo, { timeoutMs: 5000 })
expect(out.commits).toHaveLength(3)
expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first'])
expect(out.truncated).toBe(false)
// hashes are short + non-empty; at is a plausible ms timestamp
for (const c of out.commits) {
expect(c.hash.length).toBeGreaterThan(0)
expect(c.at).toBeGreaterThan(0)
}
})
it('clamps n and flags truncated when asking for fewer than exist', async () => {
const out = await getGitLog(repo, { n: 2, timeoutMs: 5000 })
expect(out.commits).toHaveLength(2)
expect(out.commits.map((c) => c.subject)).toEqual(['third', 'second'])
expect(out.truncated).toBe(true)
})
it('returns an empty result for a non-git directory (best-effort, no throw)', async () => {
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
const out = await getGitLog(plain, { timeoutMs: 5000 })
expect(out).toEqual({ commits: [], truncated: false })
await fs.rm(plain, { recursive: true, force: true })
})
})

322
test/http/git-ops.test.ts Normal file
View File

@@ -0,0 +1,322 @@
/**
* test/http/git-ops.test.ts (w4-commit-push) — the git WRITE engine.
*
* Covers src/http/git-ops.ts:
* - classifyGitError (pure: stderr → safe {status,error}, SEC-M10)
* - validateRepoFiles (realpath containment: rejects escapes/absolute/flags)
* - stageFiles (git add / restore --staged, real temp repos)
* - commit (staged commit, empty/identity/length guards)
* - push (upstream vs no-upstream argv, bare-remote real push)
*
* Pure functions are unit-tested; the git verbs run against real throwaway git
* repos in os.tmpdir() (no network — a local bare repo is the push remote),
* mirroring test/http/worktrees-create.test.ts.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import {
classifyGitError,
validateRepoFiles,
stageFiles,
commit,
push,
} from '../../src/http/git-ops.js'
const execFileP = promisify(execFile)
const TIMEOUT_MS = 15000
const OPTS = { timeoutMs: TIMEOUT_MS, maxFiles: 300 }
let gitAvailable = false
beforeAll(async () => {
try {
await execFileP('git', ['--version'])
gitAvailable = true
} catch {
gitAvailable = false
}
})
/** Init a temp git repo with one commit; returns its REALPATH (macOS /var fix). */
async function makeRepo(): Promise<string> {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-')))
const git = async (args: string[]): Promise<void> => {
await execFileP('git', args, { cwd: dir })
}
await git(['init', '-q', '-b', 'main'])
await git(['config', 'user.email', 't@t.local'])
await git(['config', 'user.name', 'tester'])
await git(['config', 'commit.gpgsign', 'false'])
await fs.writeFile(path.join(dir, 'tracked.txt'), 'v1\n', 'utf8')
await git(['add', '.'])
await git(['commit', '-q', '-m', 'init'])
return dir
}
/** A bare repo to serve as a push `origin`. Returns its REALPATH. */
async function makeBareRemote(): Promise<string> {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-bare-')))
await execFileP('git', ['init', '-q', '--bare', '-b', 'main', dir])
return dir
}
// ── classifyGitError (pure, SEC-M10) ────────────────────────────────────────────
describe('classifyGitError', () => {
const cases: Array<[string, number]> = [
['nothing to commit, working tree clean', 409],
['no changes added to commit (use "git add")', 409],
['Please tell me who you are', 400],
[' ! [rejected] main -> main (non-fast-forward)', 409],
['Updates were rejected because the tip of your current branch is behind\nfetch first', 409],
['fatal: Authentication failed for \'https://github.com/x/y.git/\'', 401],
["fatal: could not read Username for 'https://github.com': terminal prompts disabled", 401],
['git@github.com: Permission denied (publickey).', 401],
["fatal: Unable to create '/repo/.git/index.lock': File exists.", 409],
['some totally unexpected failure', 500],
]
it('maps each stderr signature to the expected status', () => {
for (const [stderr, status] of cases) {
expect(classifyGitError(stderr).status).toBe(status)
}
})
it('never leaks raw git stderr (no "fatal:" in any safe message)', () => {
for (const [stderr] of cases) {
expect(classifyGitError(stderr).error.toLowerCase()).not.toContain('fatal:')
}
})
it('is case-insensitive and tolerant of a non-string input', () => {
expect(classifyGitError('NOTHING TO COMMIT').status).toBe(409)
// @ts-expect-error — defensive: non-string must not throw
expect(classifyGitError(undefined).status).toBe(500)
})
})
// ── validateRepoFiles (realpath containment) ────────────────────────────────────
describe('validateRepoFiles', () => {
it('rejects an empty list', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, [], 300)).toBeNull()
})
it('rejects more than maxFiles entries', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['a', 'b', 'c'], 2)).toBeNull()
})
it('rejects non-string, absolute, and leading-dash entries', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, [123], 300)).toBeNull()
expect(await validateRepoFiles(repo, [''], 300)).toBeNull()
expect(await validateRepoFiles(repo, ['/etc/passwd'], 300)).toBeNull()
expect(await validateRepoFiles(repo, ['-rf'], 300)).toBeNull()
})
it('rejects a ../ traversal escape', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['../escape'], 300)).toBeNull()
})
it('rejects the repo root itself (".")', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
expect(await validateRepoFiles(repo, ['.'], 300)).toBeNull()
})
it('accepts in-repo relative paths (existing AND not-yet-on-disk / deleted)', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
await fs.writeFile(path.join(repo, 'exists.txt'), 'x', 'utf8')
const ok = await validateRepoFiles(repo, ['exists.txt', 'sub/deleted.txt'], 300)
expect(ok).toEqual(['exists.txt', 'sub/deleted.txt'])
})
it('rejects a pre-planted symlink that escapes the repo after realpath (M2)', async () => {
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-')))
const outside = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'vrf-out-')))
await fs.writeFile(path.join(outside, 'secret.txt'), 'top', 'utf8')
await fs.symlink(path.join(outside, 'secret.txt'), path.join(repo, 'link.txt'))
expect(await validateRepoFiles(repo, ['link.txt'], 300)).toBeNull()
})
})
// ── stageFiles (real temp repos) ────────────────────────────────────────────────
describe('stageFiles', () => {
it('stages the given files (git add) and unstages them (restore --staged)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8') // modify tracked
await fs.writeFile(path.join(repo, 'new.txt'), 'new\n', 'utf8') // add untracked
const staged = await stageFiles(repo, ['tracked.txt', 'new.txt'], true, OPTS)
expect(staged).toMatchObject({ ok: true, staged: true, count: 2 })
const cached = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
expect(cached).toContain('tracked.txt')
expect(cached).toContain('new.txt')
const unstaged = await stageFiles(repo, ['tracked.txt'], false, OPTS)
expect(unstaged).toMatchObject({ ok: true, staged: false, count: 1 })
const cached2 = (await execFileP('git', ['diff', '--cached', '--name-only'], { cwd: repo })).stdout
expect(cached2).not.toContain('tracked.txt')
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-plain-')))
const res = await stageFiles(plain, ['x.txt'], true, OPTS)
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('returns 400 for an escaping file path (never runs git)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await stageFiles(repo, ['../../etc/passwd'], true, OPTS)
expect(res).toMatchObject({ ok: false, status: 400 })
})
})
// ── commit (real temp repos) ────────────────────────────────────────────────────
describe('commit', () => {
it('commits staged changes and returns a short SHA', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
const res = await commit(repo, 'second commit', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res.ok).toBe(true)
expect(typeof res.commit).toBe('string')
const head = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(head.startsWith(res.commit as string)).toBe(true)
})
it('returns 409 when nothing is staged', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await commit(repo, 'noop', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res).toMatchObject({ ok: false, status: 409 })
expect(res.error).not.toContain('fatal:')
})
it('rejects an empty message with 400 (never runs git)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
expect(await commit(repo, '', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
ok: false,
status: 400,
})
expect(await commit(repo, ' \n ', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })).toMatchObject({
ok: false,
status: 400,
})
})
it('rejects an over-long message with 400', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await commit(repo, 'x'.repeat(51), { timeoutMs: TIMEOUT_MS, maxLen: 50 })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 400 when the author identity is unset', async () => {
if (!gitAvailable) return
// Init WITHOUT any local identity, then hide the global/system config so no
// identity resolves — git must fail with "Please tell me who you are" → 400.
const repo = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-noid-')))
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: repo })
// useConfigOnly=true stops git auto-detecting an identity from the OS user, so
// with no configured identity it hard-fails "Please tell me who you are".
await execFileP('git', ['config', '--local', 'user.useConfigOnly', 'true'], { cwd: repo })
await fs.writeFile(path.join(repo, 'f.txt'), 'x\n', 'utf8')
await execFileP('git', ['add', 'f.txt'], { cwd: repo }) // stage on unborn HEAD
const prevG = process.env['GIT_CONFIG_GLOBAL']
const prevS = process.env['GIT_CONFIG_SYSTEM']
process.env['GIT_CONFIG_GLOBAL'] = '/dev/null'
process.env['GIT_CONFIG_SYSTEM'] = '/dev/null'
try {
const res = await commit(repo, 'msg', { timeoutMs: TIMEOUT_MS, maxLen: 5000 })
expect(res).toMatchObject({ ok: false, status: 400 })
expect(res.error).not.toContain('fatal:')
} finally {
if (prevG === undefined) delete process.env['GIT_CONFIG_GLOBAL']
else process.env['GIT_CONFIG_GLOBAL'] = prevG
if (prevS === undefined) delete process.env['GIT_CONFIG_SYSTEM']
else process.env['GIT_CONFIG_SYSTEM'] = prevS
}
})
})
// ── push (real bare-repo remote, no network) ────────────────────────────────────
describe('push', () => {
it('first push sets upstream (-u) and the bare remote receives the ref', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(remoteHead).toBe(localHead)
})
it('a second push (upstream now set) succeeds via a plain push', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare], { cwd: repo })
expect((await push(repo, { timeoutMs: TIMEOUT_MS })).ok).toBe(true)
await fs.writeFile(path.join(repo, 'tracked.txt'), 'v2\n', 'utf8')
await execFileP('git', ['add', 'tracked.txt'], { cwd: repo })
await execFileP('git', ['commit', '-q', '-m', 'second'], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = (await execFileP('git', ['--git-dir', bare, 'rev-parse', 'main'], {})).stdout.trim()
const localHead = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
expect(remoteHead).toBe(localHead)
})
it('returns 400 for a detached HEAD', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const sha = (await execFileP('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim()
await execFileP('git', ['checkout', '-q', sha], { cwd: repo }) // detach
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 400 when the repo has zero remotes', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 409 with two remotes and no upstream (ambiguous)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const bare1 = await makeBareRemote()
const bare2 = await makeBareRemote()
await execFileP('git', ['remote', 'add', 'origin', bare1], { cwd: repo })
await execFileP('git', ['remote', 'add', 'backup', bare2], { cwd: repo })
const res = await push(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 409 })
})
it('returns 404 for a non-git directory', async () => {
const plain = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'gitops-nrepo-')))
expect(await push(plain, { timeoutMs: TIMEOUT_MS })).toMatchObject({ ok: false, status: 404 })
})
})

View File

@@ -0,0 +1,119 @@
/**
* test/http/session-groups.test.ts (W5) — pure grouping helpers, no I/O.
*
* deriveRepoRoot strips a `<name>-worktrees/<lane>` parent to `<...>/<name>`;
* groupSessionsByRepo clusters a repo + its worktree lanes into one SessionGroup,
* skips cwd:null, and preserves manager.list() (newest-first) order.
*/
import { describe, it, expect } from 'vitest'
import { deriveRepoRoot, groupSessionsByRepo } from '../../src/http/session-groups.js'
import type { LiveSessionInfo } from '../../src/types.js'
function live(id: string, cwd: string | null): LiveSessionInfo {
return {
id,
createdAt: 0,
clientCount: 0,
status: 'unknown',
exited: false,
cwd,
cols: 80,
rows: 24,
}
}
describe('deriveRepoRoot', () => {
it('strips a *-worktrees parent to the repo root', () => {
expect(deriveRepoRoot('/a/proj-worktrees/lane-1')).toBe('/a/proj')
})
it('returns the cwd unchanged when the parent is not a *-worktrees dir', () => {
expect(deriveRepoRoot('/a/proj')).toBe('/a/proj')
})
it('returns null for a null cwd', () => {
expect(deriveRepoRoot(null)).toBe(null)
})
it('returns null for an empty cwd', () => {
expect(deriveRepoRoot('')).toBe(null)
})
it('handles a nested repo path with a *-worktrees parent', () => {
expect(deriveRepoRoot('/home/me/code/web-terminal-worktrees/feat-x-lane-2')).toBe(
'/home/me/code/web-terminal',
)
})
it('does not treat a bare "-worktrees" segment (no repo name) specially', () => {
// parentName === '-worktrees' has no repo name to keep → falls through to cwd.
expect(deriveRepoRoot('/a/-worktrees/x')).toBe('/a/-worktrees/x')
})
})
describe('groupSessionsByRepo', () => {
it('returns [] for no sessions', () => {
expect(groupSessionsByRepo([])).toEqual([])
})
it('clusters a repo and its worktree lanes into ONE group', () => {
const sessions = [
live('s1', '/a/proj-worktrees/lane-1'),
live('s2', '/a/proj-worktrees/lane-2'),
]
const groups = groupSessionsByRepo(sessions)
expect(groups).toHaveLength(1)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[0]!.label).toBe('proj')
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['s1', 's2'])
})
it('groups the main repo cwd together with its worktree lanes', () => {
const sessions = [
live('main', '/a/proj'),
live('lane', '/a/proj-worktrees/feat-lane-1'),
]
const groups = groupSessionsByRepo(sessions)
expect(groups).toHaveLength(1)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['main', 'lane'])
})
it('puts an unrelated cwd in its own group', () => {
const groups = groupSessionsByRepo([
live('a', '/a/proj-worktrees/lane-1'),
live('b', '/b/other'),
])
expect(groups).toHaveLength(2)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[1]!.repoRoot).toBe('/b/other')
expect(groups[1]!.label).toBe('other')
})
it('skips sessions with a null cwd (not attributable to a repo)', () => {
const groups = groupSessionsByRepo([
live('nocwd', null),
live('real', '/a/proj'),
])
expect(groups).toHaveLength(1)
expect(groups[0]!.repoRoot).toBe('/a/proj')
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['real'])
})
it('preserves the input (newest-first) order across and within groups', () => {
const groups = groupSessionsByRepo([
live('newest', '/a/proj-worktrees/lane-2'),
live('older', '/a/proj-worktrees/lane-1'),
])
expect(groups[0]!.sessions.map((s) => s.id)).toEqual(['newest', 'older'])
})
it('does not mutate the input array', () => {
const input = [live('a', '/a/proj')]
const copy = [...input]
groupSessionsByRepo(input)
expect(input).toEqual(copy)
})
})

View File

@@ -0,0 +1,186 @@
/**
* test/http/worktrees-remove.test.ts — W4 worktree remove / prune (DESTRUCTIVE).
*
* Covers the two new exports of src/http/worktrees.ts:
* - removeWorktree (registered-check + main/locked reject + realpath match +
* dirty/force handling + safe error classification, SEC-M10)
* - pruneWorktrees (idempotent prune of stale worktrees)
*
* Everything runs against real temporary git repos with real linked worktrees
* (no network, no mocks) — mirrors worktrees-create.test.ts. Safety is the point:
* these tests assert git is never invoked against an arbitrary / main / escaping
* path, and that a dirty tree is refused without an explicit force.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createWorktree, removeWorktree, pruneWorktrees } from '../../src/http/worktrees.js'
const execFileP = promisify(execFile)
const TIMEOUT_MS = 10000
let gitAvailable = false
beforeAll(async () => {
try {
await execFileP('git', ['--version'])
gitAvailable = true
} catch {
gitAvailable = false
}
})
/** Create a temp git repo with one commit so `git worktree add` can run. */
async function makeRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-repo-'))
await execFileP('git', ['init', '-q', '-b', 'main'], { cwd: dir })
await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: dir })
await execFileP('git', ['config', 'user.name', 'tester'], { cwd: dir })
await execFileP('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir })
await execFileP('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: dir })
return dir
}
/** Create a repo + one linked worktree (via the shipped createWorktree). */
async function makeRepoWithWorktree(branch = 'feature'): Promise<{ repo: string; wt: string }> {
const repo = await makeRepo()
const res = await createWorktree(repo, branch, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
return { repo, wt: res.path as string }
}
async function worktreeList(repo: string): Promise<string> {
const { stdout } = await execFileP('git', ['worktree', 'list'], { cwd: repo })
return stdout
}
// ── removeWorktree ──────────────────────────────────────────────────────────────
describe('removeWorktree', () => {
it('returns 404 for a non-git directory', async () => {
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-plain-'))
const res = await removeWorktree(plain, plain, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('returns 400 for an empty target path', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await removeWorktree(repo, '', { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
})
it('returns 404 for a path that is not a registered worktree (never rm arbitrary paths)', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const stranger = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-stranger-'))
const res = await removeWorktree(repo, stranger, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 404 })
// The stranger dir was never touched by git.
await expect(fs.stat(stranger)).resolves.toBeDefined()
})
it('refuses to remove the MAIN worktree (the repo itself) with 400', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await removeWorktree(repo, repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 400 })
expect(res.error).toMatch(/main/i)
// The repo still exists and is still a git repo.
await expect(fs.stat(path.join(repo, '.git'))).resolves.toBeDefined()
})
it('removes a clean linked worktree and returns ok', async () => {
if (!gitAvailable) return
const { repo, wt } = await makeRepoWithWorktree()
expect(await worktreeList(repo)).toContain(wt)
const res = await removeWorktree(repo, wt, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
expect(res.path).toBeDefined()
expect(await worktreeList(repo)).not.toContain(wt)
})
it('refuses a dirty worktree without force (409), then removes it with force', async () => {
if (!gitAvailable) return
const { repo, wt } = await makeRepoWithWorktree()
// Untracked file → git refuses `worktree remove` without --force.
await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8')
const refused = await removeWorktree(repo, wt, { timeoutMs: TIMEOUT_MS })
expect(refused).toMatchObject({ ok: false, status: 409 })
// Still present — no data was lost.
expect(await worktreeList(repo)).toContain(wt)
const forced = await removeWorktree(repo, wt, { force: true, timeoutMs: TIMEOUT_MS })
expect(forced.ok).toBe(true)
expect(await worktreeList(repo)).not.toContain(wt)
})
it('never leaks raw git stderr in the dirty-tree error (SEC-M10)', async () => {
if (!gitAvailable) return
const { repo, wt } = await makeRepoWithWorktree()
await fs.writeFile(path.join(wt, 'scratch.txt'), 'wip\n', 'utf8')
const res = await removeWorktree(repo, wt, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(false)
expect(res.error).toBeDefined()
expect(res.error).not.toContain('fatal:')
expect(res.error).not.toContain('error:')
expect(res.error).not.toContain(wt) // no path leak
})
it('refuses a locked worktree with 409 (unlock first; never auto -f -f)', async () => {
if (!gitAvailable) return
const { repo, wt } = await makeRepoWithWorktree()
await execFileP('git', ['worktree', 'lock', wt], { cwd: repo })
const res = await removeWorktree(repo, wt, { force: true, timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 409 })
expect(res.error).toMatch(/lock/i)
expect(await worktreeList(repo)).toContain(wt)
})
it('matches a symlink alias via realpath and removes git\'s canonical path (M2)', async () => {
if (!gitAvailable) return
const { repo, wt } = await makeRepoWithWorktree()
const aliasParent = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-alias-'))
const alias = path.join(aliasParent, 'link-to-wt')
await fs.symlink(wt, alias)
// Passing the symlink still resolves to the same registered worktree.
const res = await removeWorktree(repo, alias, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
expect(await worktreeList(repo)).not.toContain(wt)
})
})
// ── pruneWorktrees ──────────────────────────────────────────────────────────────
describe('pruneWorktrees', () => {
it('returns 404 for a non-git directory', async () => {
const plain = await fs.mkdtemp(path.join(os.tmpdir(), 'wtrm-pruneplain-'))
const res = await pruneWorktrees(plain, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: false, status: 404 })
})
it('prunes a worktree whose folder was deleted out-of-band', async () => {
if (!gitAvailable) return
const { repo, wt } = await makeRepoWithWorktree()
// Simulate a user rm -rf'ing the worktree dir behind git's back.
await fs.rm(wt, { recursive: true, force: true })
const res = await pruneWorktrees(repo, { timeoutMs: TIMEOUT_MS })
expect(res.ok).toBe(true)
expect(Array.isArray(res.pruned)).toBe(true)
expect((res.pruned as string[]).length).toBeGreaterThanOrEqual(1)
// git no longer lists the stale worktree.
expect(await worktreeList(repo)).not.toContain(wt)
})
it('is idempotent — a clean repo prunes nothing', async () => {
if (!gitAvailable) return
const repo = await makeRepo()
const res = await pruneWorktrees(repo, { timeoutMs: TIMEOUT_MS })
expect(res).toMatchObject({ ok: true })
expect(res.pruned).toEqual([])
})
})

View File

@@ -0,0 +1,337 @@
/**
* test/integration/auth.test.ts — real-server integration for the optional
* access-token gate (w5-access-token). Pattern mirrors integration/server.test.ts.
*
* Two axes:
* - DISABLED (no WEBTERM_TOKEN): everything works byte-identically to today —
* WS connects on Origin alone, state-changing routes gated only by Origin.
* - ENABLED: the gate + WS cookie check activate ALONGSIDE (never replacing)
* the Origin/CSRF model.
*
* All traffic here is loopback (127.0.0.1). The gate's loopback bypass is scoped
* to the /hook* ingest paths only, so non-hook routes are genuinely gated even
* from 127.0.0.1 — which is what makes the "unauthed → 401/302" cases testable.
*
* Each test gets its own port + server (beforeEach) so the per-IP auth rate
* limiter never leaks across cases.
*/
import net from 'node:net'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { Config } from '../../src/types.js'
const TOKEN = 'integration-secret-token-01' // ≥16, safe charset
const COOKIE = `webterm_auth=${TOKEN}`
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (!addr || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
/** Build a config, optionally with WEBTERM_TOKEN set (auth enabled). */
function makeCfg(port: number, withToken: boolean): Config {
const env: Record<string, string> = {
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
SCROLLBACK_BYTES: String(64 * 1024),
IDLE_TTL: '86400',
USE_TMUX: '0',
}
if (withToken) env['WEBTERM_TOKEN'] = TOKEN
return loadConfig(env)
}
/** Resolve iff the WS opens; reject if it errors/closes before open. */
function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs)
ws.once('open', () => {
clearTimeout(timer)
resolve()
})
ws.once('error', (err) => {
clearTimeout(timer)
reject(err)
})
ws.once('close', () => {
clearTimeout(timer)
reject(new Error('WS closed before open'))
})
})
}
// ── DISABLED: zero behaviour change (the regression proof) ──────────────────────
describe('access-token gate — DISABLED (WEBTERM_TOKEN unset)', () => {
let port: number
let handle: { close(): Promise<void> }
beforeEach(async () => {
port = await getFreePort()
handle = startServer(makeCfg(port, false))
await new Promise<void>((r) => setTimeout(r, 80))
})
afterEach(async () => {
await handle.close()
await new Promise<void>((r) => setTimeout(r, 50))
})
it('WS handshake succeeds with a valid Origin and NO cookie (unchanged)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await expect(waitForOpen(ws)).resolves.toBeUndefined()
ws.terminate()
})
it('GET /live-sessions is open (no cookie required)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.status).toBe(200)
expect(Array.isArray(await res.json())).toBe(true)
})
it('GET / serves the app (200, no login redirect)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/`, {
headers: { Accept: 'text/html' },
redirect: 'manual',
})
expect(res.status).toBe(200)
})
it('DELETE /live-sessions works with a valid Origin and no cookie (Origin-only, as today)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}` },
})
expect(res.status).toBe(200)
})
it('DELETE /live-sessions still 403s a foreign Origin (CSRF layer intact)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example' },
})
expect(res.status).toBe(403)
})
})
// ── ENABLED: the gate + WS cookie check activate ────────────────────────────────
describe('access-token gate — ENABLED (WEBTERM_TOKEN set)', () => {
let port: number
let handle: { close(): Promise<void> }
beforeEach(async () => {
port = await getFreePort()
handle = startServer(makeCfg(port, true))
await new Promise<void>((r) => setTimeout(r, 80))
})
afterEach(async () => {
await handle.close()
await new Promise<void>((r) => setTimeout(r, 50))
})
// ── WS handshake ──────────────────────────────────────────────────────────
it('WS: valid Origin + NO cookie → handshake rejected (401)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await expect(waitForOpen(ws)).rejects.toThrow()
ws.terminate()
})
it('WS: valid Origin + WRONG cookie → handshake rejected (401)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}`, Cookie: 'webterm_auth=wrong-value-000000' },
})
await expect(waitForOpen(ws)).rejects.toThrow()
ws.terminate()
})
it('WS: valid Origin + VALID cookie → handshake accepted (opens)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE },
})
await expect(waitForOpen(ws)).resolves.toBeUndefined()
ws.terminate()
})
it('WS: the Origin check still runs FIRST — bad Origin + valid cookie → 401 (additive, not replacing)', async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, {
headers: { Origin: 'http://evil.example', Cookie: COOKIE },
})
await expect(waitForOpen(ws)).rejects.toThrow()
ws.terminate()
})
// ── POST /auth ────────────────────────────────────────────────────────────
it('POST /auth with the WRONG token → 401 JSON (XHR)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: 'nope-nope-nope-nope-00' }),
})
expect(res.status).toBe(401)
})
it('POST /auth with the CORRECT token (form) → 302 + Set-Cookie with HttpOnly/SameSite/Path/Max-Age, NO Secure over http', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'text/html' },
body: `token=${encodeURIComponent(TOKEN)}`,
redirect: 'manual',
})
expect(res.status).toBe(302)
const setCookie = res.headers.get('set-cookie') ?? ''
expect(setCookie).toContain(`webterm_auth=${TOKEN}`)
expect(setCookie).toContain('HttpOnly')
expect(setCookie).toContain('SameSite=Strict')
expect(setCookie).toContain('Path=/')
expect(setCookie).toContain('Max-Age=')
expect(setCookie).not.toContain('Secure')
})
it('POST /auth with the CORRECT token (XHR) → 204 + Set-Cookie', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: TOKEN }),
})
expect(res.status).toBe(204)
expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=')
})
it('POST /auth over x-forwarded-proto: https → Set-Cookie INCLUDES Secure', async () => {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' },
body: JSON.stringify({ token: TOKEN }),
})
expect(res.status).toBe(204)
expect(res.headers.get('set-cookie') ?? '').toContain('Secure')
})
it('POST /auth is rate-limited: >10 bad attempts/min → 429', async () => {
let saw429 = false
for (let i = 0; i < 12; i++) {
const res = await fetch(`http://127.0.0.1:${port}/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: 'bad-attempt-value-000' }),
})
if (res.status === 429) saw429 = true
}
expect(saw429).toBe(true)
})
// ── GET /?token= bootstrap ──────────────────────────────────────────────────
it('GET /?token=<valid> → 302 to a path with NO token + Set-Cookie', async () => {
const res = await fetch(`http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}`, {
redirect: 'manual',
})
expect(res.status).toBe(302)
const loc = res.headers.get('location') ?? ''
expect(loc).not.toContain('token')
expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=')
})
it('GET /?token=<invalid> → 302 to /login, NO cookie', async () => {
const res = await fetch(`http://127.0.0.1:${port}/?token=wrong-token-value-00`, {
redirect: 'manual',
})
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('/login')
expect(res.headers.get('set-cookie')).toBeNull()
})
// ── /login reachable + unauthed routing ─────────────────────────────────────
it('GET /login → 200 HTML, reachable while unauthed', async () => {
const res = await fetch(`http://127.0.0.1:${port}/login`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type') ?? '').toContain('text/html')
expect(await res.text()).toContain('<form')
})
it('GET /login?e=1 → the error banner is revealed (show-error)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/login?e=1`)
expect(await res.text()).toContain('show-error')
})
it('unauthed HTML navigation (GET /, no cookie) → 302 /login', async () => {
const res = await fetch(`http://127.0.0.1:${port}/`, {
headers: { Accept: 'text/html' },
redirect: 'manual',
})
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('/login')
})
it('unauthed XHR (GET /live-sessions, no cookie) → 401 JSON', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`)
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'authentication required' })
})
it('authed XHR (GET /live-sessions with valid cookie) → 200', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
headers: { Cookie: COOKIE },
})
expect(res.status).toBe(200)
expect(Array.isArray(await res.json())).toBe(true)
})
// ── Gate + CSRF stacking on a state-changing route ──────────────────────────
it('DELETE /live-sessions, valid Origin, NO cookie → 401 (gate blocks before CSRF)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}` },
})
expect(res.status).toBe(401)
})
it('DELETE /live-sessions, valid Origin + valid cookie → 200 (passes gate, then CSRF)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE },
})
expect(res.status).toBe(200)
})
it('DELETE /live-sessions, valid cookie but FOREIGN Origin → 403 (CSRF layer still independent)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example', Cookie: COOKIE },
})
expect(res.status).toBe(403)
})
// ── Loopback hook ingest bypass (guard against regressing the side-channel) ──
it('POST /hook from loopback with NO cookie still returns 204 (hooks unaffected)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webterm-session': '00000000-0000-4000-8000-000000000000',
},
body: JSON.stringify({ hook_event_name: 'Stop' }),
})
expect(res.status).toBe(204)
})
})

View File

@@ -0,0 +1,78 @@
/**
* Integration test for GET /digest (W3 quick-wins c).
*
* Starts a real HTTP server (no live sessions) and asserts the read-only digest
* route: 200 + a well-shaped DigestResult; a malformed `since` clamps to 0.
*/
import net from 'node:net'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { DigestResult } from '../../src/types.js'
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
describe('GET /digest — integration', () => {
let port: number
let serverHandle: { close(): Promise<void> }
beforeAll(async () => {
port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
})
it('returns 200 with an all-zero DigestResult when no sessions', async () => {
const res = await fetch(`http://127.0.0.1:${port}/digest?since=0`)
expect(res.status).toBe(200)
const body = (await res.json()) as DigestResult
expect(body.since).toBe(0)
expect(body.total).toBe(0)
expect(body.finished).toBe(0)
expect(Array.isArray(body.sessions)).toBe(true)
expect(typeof body.generatedAt).toBe('number')
})
it('clamps a malformed since to 0', async () => {
const res = await fetch(`http://127.0.0.1:${port}/digest?since=not-a-number`)
expect(res.status).toBe(200)
const body = (await res.json()) as DigestResult
expect(body.since).toBe(0)
})
it('echoes a valid since watermark', async () => {
const res = await fetch(`http://127.0.0.1:${port}/digest?since=12345`)
const body = (await res.json()) as DigestResult
expect(body.since).toBe(12345)
})
})

View File

@@ -0,0 +1,237 @@
/**
* test/integration/git-ops.test.ts (w4-commit-push) — the three git-WRITE routes
* against a real startServer:
* POST /projects/git/stage — Origin guard, kill-switch, missing field, non-git,
* real stage in a temp repo
* POST /projects/git/commit — same guards + real commit + safe 409/400 bodies
* POST /projects/git/push — same guards + real push to a temp BARE remote
*
* Mirrors test/integration/worktree.test.ts (startServer + fetch + Origin + real
* temp repos). No network — a local bare repo is the push remote.
*/
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
const GIT_AVAILABLE = (() => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' })
return true
} catch {
return false
}
})()
const itGit = GIT_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('bad addr'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
const handles: { close(): Promise<void> }[] = []
const tmpDirs: string[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
...overrides,
})
const handle = startServer(cfg)
handles.push(handle)
await new Promise<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
/** Temp git repo on `main` with one committed file. Returns its absolute path. */
async function makeRealRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-'))
tmpDirs.push(dir)
const git = (args: string[]): void => {
execFileSync('git', args, { cwd: dir, stdio: 'ignore' })
}
git(['init', '-b', 'main'])
git(['config', 'user.email', 'test@localhost'])
git(['config', 'user.name', 'Test'])
git(['config', 'commit.gpgsign', 'false'])
await fs.writeFile(path.join(dir, 'file.txt'), 'line1\nline2\n', 'utf8')
git(['add', '.'])
git(['commit', '-m', 'init'])
return dir
}
/** A bare repo to serve as a push `origin`. Returns its absolute path. */
async function makeBareRemote(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gitops-bare-'))
tmpDirs.push(dir)
execFileSync('git', ['init', '--bare', '-b', 'main', dir], { stdio: 'ignore' })
return dir
}
async function postJson(
port: number,
route: string,
body: unknown,
headers: Record<string, string>,
): Promise<Response> {
return fetch(`http://127.0.0.1:${port}${route}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
}
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
for (const d of tmpDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }).catch(() => undefined)
await new Promise<void>((r) => setTimeout(r, 30))
})
// ── shared guard matrix ─────────────────────────────────────────────────────────
const ROUTES = ['/projects/git/stage', '/projects/git/commit', '/projects/git/push'] as const
describe('git-write routes — shared guards (CSRF / kill-switch)', () => {
for (const route of ROUTES) {
it(`${route}: rejects a foreign Origin with 403`, async () => {
const { port } = await spawnServer()
const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: 'http://evil.example' })
expect(res.status).toBe(403)
})
it(`${route}: rejects a missing Origin with 403 (default-deny)`, async () => {
const { port } = await spawnServer()
const res = await postJson(port, route, { path: '/tmp/x' }, {})
expect(res.status).toBe(403)
})
it(`${route}: returns 403 when GIT_OPS_ENABLED=0`, async () => {
const { port, origin } = await spawnServer({ GIT_OPS_ENABLED: '0' })
const res = await postJson(port, route, { path: '/tmp/x' }, { Origin: origin })
expect(res.status).toBe(403)
})
}
})
// ── POST /projects/git/stage ──────────────────────────────────────────────────────
describe('POST /projects/git/stage', () => {
it('returns 400 when files are missing', async () => {
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/stage', { path: '/tmp/x' }, { Origin: origin })
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-nogit-'))
tmpDirs.push(dir)
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/stage', { path: dir, files: ['a'] }, { Origin: origin })
expect(res.status).toBe(404)
})
itGit('stages a modified file in a real repo (200) and git records it', async () => {
const repo = await makeRealRepo()
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8')
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/stage', { path: repo, files: ['file.txt'] }, { Origin: origin })
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; staged: boolean; count: number }
expect(body).toMatchObject({ ok: true, staged: true, count: 1 })
const cached = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repo }).toString()
expect(cached).toContain('file.txt')
})
})
// ── POST /projects/git/commit ─────────────────────────────────────────────────────
describe('POST /projects/git/commit', () => {
itGit('returns 400 for an empty message', async () => {
const repo = await makeRealRepo()
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/commit', { path: repo, message: ' ' }, { Origin: origin })
expect(res.status).toBe(400)
})
itGit('returns a safe 409 (no "fatal:") when nothing is staged', async () => {
const repo = await makeRealRepo()
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'noop' }, { Origin: origin })
expect(res.status).toBe(409)
const body = (await res.json()) as { ok: boolean; error: string }
// Failure body carries ok:false + a SAFE message so the FE guard surfaces it.
expect(body.ok).toBe(false)
expect(body.error).not.toContain('fatal:')
expect(body.error).toBe('Nothing staged to commit.')
})
itGit('commits staged changes and returns a short SHA (200)', async () => {
const repo = await makeRealRepo()
await fs.writeFile(path.join(repo, 'file.txt'), 'line1\nCHANGED\n', 'utf8')
execFileSync('git', ['add', 'file.txt'], { cwd: repo })
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/commit', { path: repo, message: 'phone commit' }, { Origin: origin })
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; commit: string }
expect(body.ok).toBe(true)
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim()
expect(head.startsWith(body.commit)).toBe(true)
})
})
// ── POST /projects/git/push ───────────────────────────────────────────────────────
describe('POST /projects/git/push', () => {
itGit('returns a safe 400 when the repo has no remote', async () => {
const repo = await makeRealRepo()
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin })
expect(res.status).toBe(400)
const body = (await res.json()) as { error: string }
expect(body.error).not.toContain('fatal:')
})
itGit('pushes the current branch to a bare remote and sets upstream (200)', async () => {
const repo = await makeRealRepo()
const bare = await makeBareRemote()
execFileSync('git', ['remote', 'add', 'origin', bare], { cwd: repo })
const { port, origin } = await spawnServer()
const res = await postJson(port, '/projects/git/push', { path: repo }, { Origin: origin })
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean; branch: string; remote: string }
expect(body).toMatchObject({ ok: true, branch: 'main', remote: 'origin' })
const remoteHead = execFileSync('git', ['--git-dir', bare, 'rev-parse', 'main']).toString().trim()
const localHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo }).toString().trim()
expect(remoteHead).toBe(localHead)
})
})

View File

@@ -0,0 +1,172 @@
/**
* Integration test for GET /live-sessions/grouped (W5 fan-out board) and the
* /config/ui maxFanoutLanes field.
*
* Starts a real HTTP server. The grouped route is read-only (no Origin guard) and
* derives groups purely from cwd — the empty-manager path needs no PTY. The
* real-PTY case (two sessions under one *-worktrees dir clustering into one group)
* is itPty-gated: posix_spawn is blocked in the sandbox, so it auto-skips there
* and runs in CI.
*/
import net from 'node:net'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { SessionGroup, UiConfig } from '../../src/types.js'
const PTY_AVAILABLE = (() => {
try {
const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
p.kill()
return true
} catch {
return false
}
})()
const itPty = PTY_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs)
ws.once('open', () => {
clearTimeout(timer)
resolve()
})
ws.once('error', (err) => {
clearTimeout(timer)
reject(err)
})
})
}
/** Resolve once an 'attached' frame arrives (output frames may interleave). */
function waitForAttached(ws: WebSocket, timeoutMs = 4_000): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('attached timeout')), timeoutMs)
const onMsg = (raw: WebSocket.RawData): void => {
let msg: { type?: string }
try {
msg = JSON.parse(raw.toString('utf8'))
} catch {
return
}
if (msg.type === 'attached') {
clearTimeout(timer)
ws.off('message', onMsg)
resolve()
}
}
ws.on('message', onMsg)
})
}
describe('GET /live-sessions/grouped + /config/ui maxFanoutLanes — integration', () => {
let port: number
let serverHandle: { close(): Promise<void> }
const origin = (): string => `http://127.0.0.1:${port}`
beforeAll(async () => {
port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
MAX_FANOUT_LANES: '5',
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
})
it('returns 200 [] on an empty manager, WITHOUT requiring an Origin header', async () => {
const res = await fetch(`${origin()}/live-sessions/grouped`)
expect(res.status).toBe(200) // read-only: not 403 even with no Origin
const body = (await res.json()) as SessionGroup[]
expect(Array.isArray(body)).toBe(true)
expect(body).toHaveLength(0)
})
it('does not shadow "grouped" as a session :id (returns an array, not an error)', async () => {
const res = await fetch(`${origin()}/live-sessions/grouped`)
const body = await res.json()
expect(Array.isArray(body)).toBe(true)
})
it('exposes maxFanoutLanes on /config/ui', async () => {
const res = await fetch(`${origin()}/config/ui`)
expect(res.status).toBe(200)
const body = (await res.json()) as UiConfig
expect(body.maxFanoutLanes).toBe(5)
})
// ── real-PTY: two sessions under one *-worktrees dir cluster into one group ──
let tmpRepo: string | null = null
const sockets: WebSocket[] = []
afterEach(() => {
for (const ws of sockets.splice(0)) {
try {
ws.close()
} catch {
// ignore
}
}
})
itPty('clusters two worktree-lane sessions into a single group', async () => {
tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'fanout-'))
const laneDir = path.join(tmpRepo, 'proj-worktrees')
const lane1 = path.join(laneDir, 'lane-1')
const lane2 = path.join(laneDir, 'lane-2')
await fs.mkdir(lane1, { recursive: true })
await fs.mkdir(lane2, { recursive: true })
for (const cwd of [lane1, lane2]) {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { origin: origin() })
sockets.push(ws)
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null, cwd }))
await waitForAttached(ws)
}
// Give the sessions a beat to register their cwd.
await new Promise<void>((r) => setTimeout(r, 100))
const res = await fetch(`${origin()}/live-sessions/grouped`)
const groups = (await res.json()) as SessionGroup[]
const proj = groups.find((g) => g.repoRoot === path.join(tmpRepo!, 'proj'))
expect(proj).toBeDefined()
expect(proj!.label).toBe('proj')
expect(proj!.sessions.length).toBe(2)
})
})

View File

@@ -0,0 +1,175 @@
/**
* Integration test for GET /projects/pr (W3 PR + CI status chip).
*
* Starts a real HTTP server against a temp git repo and stubs `gh` with a PATH
* shim: an executable script named `gh` in a temp bin/ dir prepended to PATH, so
* the server's execFile('gh', …) resolves OUR script (which echoes canned JSON or
* exits 1 with a canned stderr). Asserts:
* - missing ?path → 400
* - non-git ?path → 404 (isValidGitDir three-prong)
* - gh emits valid PR → 200 {availability:'ok', checks:…}
* - gh exits 1 (no PR) → 200 {availability:'no-pr'}
* - GH_ENABLED=0 → 200 {availability:'disabled'} and gh is NEVER spawned
*
* Determinism: PROJECT_SCAN_TTL=0 disables the gh module cache; _clearPrCache()
* runs in afterEach; each test writes the exact gh shim it needs.
*/
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import { _clearPrCache } from '../../src/http/gh.js'
import type { PrStatus } from '../../src/types.js'
// ── helpers ───────────────────────────────────────────────────────────────────
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
async function makeFakeGitRepo(parentDir: string, repoName: string): Promise<string> {
const repoPath = path.join(parentDir, repoName)
await fs.mkdir(path.join(repoPath, '.git'), { recursive: true })
await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8')
return repoPath
}
/** Write an executable `gh` shim into binDir with the given /bin/sh body. */
async function writeGhShim(binDir: string, body: string): Promise<void> {
const file = path.join(binDir, 'gh')
await fs.writeFile(file, `#!/bin/sh\n${body}\n`, 'utf8')
await fs.chmod(file, 0o755)
}
const okJson = JSON.stringify({
number: 7,
state: 'OPEN',
title: 'A pull request',
url: 'https://github.com/o/r/pull/7',
isDraft: false,
mergeable: 'MERGEABLE',
headRefName: 'main',
baseRefName: 'main',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null },
],
})
// ── suite ─────────────────────────────────────────────────────────────────────
describe('GET /projects/pr — integration', () => {
let tmpRoot: string
let repoPath: string
let binDir: string
let originalPath: string
let serverHandle: { close(): Promise<void> } | null = null
beforeAll(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pr-test-'))
repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo')
binDir = path.join(tmpRoot, 'bin')
await fs.mkdir(binDir, { recursive: true })
// Prepend our shim dir so execFile('gh', …) resolves OUR gh, not the host's.
originalPath = process.env['PATH'] ?? ''
process.env['PATH'] = `${binDir}${path.delimiter}${originalPath}`
})
afterAll(async () => {
process.env['PATH'] = originalPath
await fs.rm(tmpRoot, { recursive: true, force: true })
})
afterEach(async () => {
if (serverHandle !== null) {
await serverHandle.close()
serverHandle = null
}
_clearPrCache()
})
/** Start a server with the given env overrides; returns its base URL. */
async function start(overrides: Record<string, string> = {}): Promise<string> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
PROJECT_SCAN_TTL: '0', // no gh cache between requests
...overrides,
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
return `http://127.0.0.1:${port}`
}
it('returns 400 when ?path is missing', async () => {
const base = await start()
const res = await fetch(`${base}/projects/pr`)
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory path', async () => {
const base = await start()
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(tmpRoot)}`)
expect(res.status).toBe(404)
})
it('returns 200 {availability:ok} with a checks summary when gh emits PR JSON', async () => {
await writeGhShim(binDir, `cat <<'JSON'\n${okJson}\nJSON`)
const base = await start()
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as PrStatus
expect(body.availability).toBe('ok')
expect(body.number).toBe(7)
expect(body.state).toBe('open')
expect(body.checks).toEqual({ total: 3, passing: 2, failing: 0, pending: 1 })
})
it('returns 200 {availability:no-pr} when gh exits 1 with a no-PR stderr', async () => {
await writeGhShim(binDir, 'echo "no pull requests found for branch \\"main\\"" 1>&2\nexit 1')
const base = await start()
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as PrStatus
expect(body.availability).toBe('no-pr')
})
it('returns 200 {availability:disabled} and never spawns gh when GH_ENABLED=0', async () => {
const marker = path.join(tmpRoot, 'gh-was-spawned')
await fs.rm(marker, { force: true })
// This shim would create a marker if ever invoked.
await writeGhShim(binDir, `touch "${marker}"\necho '{}'`)
const base = await start({ GH_ENABLED: '0' })
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as PrStatus
expect(body).toEqual({ availability: 'disabled' })
// gh must never have run.
await expect(fs.stat(marker)).rejects.toBeDefined()
})
})

View File

@@ -0,0 +1,136 @@
/**
* Integration test for GET /projects/log (W3 quick-wins d).
*
* Starts a real HTTP server, then asserts the recent-commit log route against a
* real temp git repo (3 commits): 200 + structured GitLogResult; missing path →
* 400; a non-git temp dir → 404 (isValidGitDir three-prong); ?n clamped.
*/
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import type { GitLogResult } from '../../src/types.js'
const execFileAsync = promisify(execFile)
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
async function git(cwd: string, ...args: string[]): Promise<void> {
await execFileAsync('git', args, { cwd })
}
async function gitAvailable(): Promise<boolean> {
try {
await execFileAsync('git', ['--version'])
return true
} catch {
return false
}
}
describe('GET /projects/log — integration', () => {
let port: number
let tmpRoot: string
let repoPath: string
let plainPath: string
let serverHandle: { close(): Promise<void> }
let haveGit = false
beforeAll(async () => {
haveGit = await gitAvailable()
port = await getFreePort()
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-log-test-'))
repoPath = path.join(tmpRoot, 'repo')
plainPath = path.join(tmpRoot, 'plain')
await fs.mkdir(plainPath, { recursive: true })
if (haveGit) {
await fs.mkdir(repoPath, { recursive: true })
await git(repoPath, 'init', '-q', '-b', 'main')
await git(repoPath, 'config', 'user.email', 't@t.local')
await git(repoPath, 'config', 'user.name', 'tester')
await git(repoPath, 'config', 'commit.gpgsign', 'false')
for (const [file, msg] of [['a.txt', 'first'], ['b.txt', 'second'], ['c.txt', 'third']]) {
await fs.writeFile(path.join(repoPath, file), `${file}\n`)
await git(repoPath, 'add', '.')
await git(repoPath, 'commit', '-q', '-m', msg)
}
}
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
await fs.rm(tmpRoot, { recursive: true, force: true })
})
it('returns 400 when path is missing', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects/log`)
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory (isValidGitDir three-prong)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(plainPath)}`)
expect(res.status).toBe(404)
})
it('returns 200 with the recent commits newest-first', async () => {
if (!haveGit) return
const res = await fetch(`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as GitLogResult
expect(body.commits.map((c) => c.subject)).toEqual(['third', 'second', 'first'])
})
it('clamps ?n and flags truncated', async () => {
if (!haveGit) return
const res = await fetch(
`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}&n=2`,
)
const body = (await res.json()) as GitLogResult
expect(body.commits).toHaveLength(2)
expect(body.truncated).toBe(true)
// A huge n must be clamped to ≤ 50 (never explodes) — here only 3 commits exist.
const res2 = await fetch(
`http://127.0.0.1:${port}/projects/log?path=${encodeURIComponent(repoPath)}&n=999`,
)
const body2 = (await res2.json()) as GitLogResult
expect(body2.commits.length).toBeLessThanOrEqual(50)
expect(body2.commits).toHaveLength(3)
})
})

View File

@@ -0,0 +1,395 @@
/**
* W2 — integration tests for the server-side PTY-inject queue routes and the
* idle-drain wiring, against a real startServer.
*
* Sandbox-runnable (no PTY): Origin/CSRF guard, id/text validation, size + depth
* caps, rate limit, QUEUE_ENABLED=0 → 503, unknown-session 404.
*
* itPty-gated (needs a real shell — auto-skips where posix_spawn is blocked):
* - happy path: POST queue → 200 {length:1}; GET /live-sessions shows
* queueLength; GET/DELETE .../queue.
* - idle-drain: enqueue → POST Stop → the marker echoes through the PTY.
* - one-per-idle pacing: two entries, one Stop drains only the first.
* - settle guard: a non-idle hook during the window cancels the drain.
*/
import net from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
import * as nodePty from 'node-pty'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
const PTY_AVAILABLE = (() => {
try {
const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 })
p.kill()
return true
} catch {
return false
}
})()
const itPty = PTY_AVAILABLE ? it : it.skip
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('bad addr'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
const handles: { close(): Promise<void> }[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): Promise<{ port: number; origin: string }> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
...overrides,
})
const handle = startServer(cfg)
handles.push(handle)
await new Promise<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
function waitForOpen(ws: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('open timeout')), 3000)
ws.once('open', () => {
clearTimeout(t)
resolve()
})
ws.once('error', (e) => {
clearTimeout(t)
reject(e)
})
})
}
function waitForMessage(
ws: WebSocket,
pred: (m: Record<string, unknown>) => boolean,
timeoutMs = 6000,
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => {
ws.off('message', onMsg)
reject(new Error('message timeout'))
}, timeoutMs)
const onMsg = (raw: WebSocket.RawData): void => {
let m: Record<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
/** Resolve once no WS message has arrived for `quietMs` (the shell prompt settled). */
function waitForQuiet(ws: WebSocket, quietMs = 300, maxMs = 4000): Promise<void> {
return new Promise((resolve) => {
let quietTimer: ReturnType<typeof setTimeout>
const done = (): void => {
clearTimeout(quietTimer)
clearTimeout(hardStop)
ws.off('message', onMsg)
resolve()
}
const arm = (): void => {
clearTimeout(quietTimer)
quietTimer = setTimeout(done, quietMs)
}
const onMsg = (): void => arm()
const hardStop = setTimeout(done, maxMs)
ws.on('message', onMsg)
arm()
})
}
/** Collect every output-frame's data string for `windowMs`. */
function collectOutput(ws: WebSocket, windowMs: number): Promise<string> {
return new Promise((resolve) => {
let acc = ''
const onMsg = (raw: WebSocket.RawData): void => {
try {
const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
if (m['type'] === 'output') acc += String(m['data'])
} catch {
/* ignore */
}
}
ws.on('message', onMsg)
setTimeout(() => {
ws.off('message', onMsg)
resolve(acc)
}, windowMs)
})
}
const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'
async function attachSession(port: number, origin: string): Promise<{ ws: WebSocket; sessionId: string }> {
const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } })
await waitForOpen(ws)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = await waitForMessage(ws, isAttached)
return { ws, sessionId: attached['sessionId'] as string }
}
function postQueue(
port: number,
id: string,
body: unknown,
headers: Record<string, string> = {},
): Promise<globalThis.Response> {
return fetch(`http://127.0.0.1:${port}/live-sessions/${id}/queue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
}
function postStop(port: number, sessionId: string): Promise<globalThis.Response> {
return fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ hook_event_name: 'Stop' }),
})
}
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
await new Promise<void>((r) => setTimeout(r, 30))
})
const UNKNOWN = '00000000-0000-4000-8000-000000000000'
// ── Route guards + validation (no PTY needed) ─────────────────────────────────
describe('POST /live-sessions/:id/queue — guards & validation', () => {
it('rejects a foreign Origin with 403 (CSRF)', async () => {
const { port } = await spawnServer()
const res = await postQueue(port, UNKNOWN, { text: 'x' }, { Origin: 'http://evil.example' })
expect(res.status).toBe(403)
})
it('rejects a missing Origin with 403 (default-deny)', async () => {
const { port } = await spawnServer()
const res = await postQueue(port, UNKNOWN, { text: 'x' }) // no Origin header
expect(res.status).toBe(403)
})
it('rejects a malformed session id with 400', async () => {
const { port, origin } = await spawnServer()
const res = await postQueue(port, 'not-a-uuid', { text: 'x' }, { Origin: origin })
expect(res.status).toBe(400)
})
it('rejects empty / non-string text with 400', async () => {
const { port, origin } = await spawnServer()
expect((await postQueue(port, UNKNOWN, { text: '' }, { Origin: origin })).status).toBe(400)
expect((await postQueue(port, UNKNOWN, { text: 42 }, { Origin: origin })).status).toBe(400)
expect((await postQueue(port, UNKNOWN, {}, { Origin: origin })).status).toBe(400)
})
it('rejects oversized text with 413', async () => {
const { port, origin } = await spawnServer({ QUEUE_ITEM_MAX_BYTES: '16' })
const res = await postQueue(port, UNKNOWN, { text: 'x'.repeat(17) }, { Origin: origin })
expect(res.status).toBe(413)
})
it('returns 404 for a well-formed but unknown session id', async () => {
const { port, origin } = await spawnServer()
const res = await postQueue(port, UNKNOWN, { text: 'x' }, { Origin: origin })
expect(res.status).toBe(404)
})
it('returns 503 when QUEUE_ENABLED=0', async () => {
const { port, origin } = await spawnServer({ QUEUE_ENABLED: '0' })
const res = await postQueue(port, UNKNOWN, { text: 'x' }, { Origin: origin })
expect(res.status).toBe(503)
})
it('rate-limits after QUEUE_RATE_MAX requests with 429', async () => {
const { port, origin } = await spawnServer()
// 20 allowed (each 400 on empty text — rate is checked before validation),
// the 21st exceeds the per-IP window and returns 429.
let sawRate = false
for (let i = 0; i < 21; i++) {
const res = await postQueue(port, UNKNOWN, { text: '' }, { Origin: origin })
if (res.status === 429) sawRate = true
}
expect(sawRate).toBe(true)
})
})
describe('GET/DELETE /live-sessions/:id/queue — guards', () => {
it('GET returns 404 for an unknown session', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/queue`)
expect(res.status).toBe(404)
})
it('DELETE rejects a foreign Origin with 403', async () => {
const { port } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/queue`, {
method: 'DELETE',
headers: { Origin: 'http://evil.example' },
})
expect(res.status).toBe(403)
})
it('DELETE returns 404 for an unknown session', async () => {
const { port, origin } = await spawnServer()
const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/queue`, {
method: 'DELETE',
headers: { Origin: origin },
})
expect(res.status).toBe(404)
})
})
// ── Happy path + drain (real PTY) ─────────────────────────────────────────────
describe('inject queue — real session', () => {
itPty('enqueues, surfaces queueLength, lists items, and clears', async () => {
const { port, origin } = await spawnServer()
const { ws, sessionId } = await attachSession(port, origin)
await waitForQuiet(ws)
const enq = await postQueue(port, sessionId, { text: 'echo hi', appendEnter: true }, { Origin: origin })
expect(enq.status).toBe(200)
expect(await enq.json()).toEqual({ length: 1 })
// /live-sessions surfaces the depth.
const live = (await (await fetch(`http://127.0.0.1:${port}/live-sessions`)).json()) as Array<{
id: string
queueLength?: number
}>
expect(live.find((s) => s.id === sessionId)?.queueLength).toBe(1)
// GET .../queue returns the stored (verbatim) items.
const view = (await (
await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/queue`)
).json()) as { length: number; items: string[] }
expect(view.length).toBe(1)
expect(view.items).toEqual(['echo hi\r'])
// DELETE clears the queue.
const del = await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/queue`, {
method: 'DELETE',
headers: { Origin: origin },
})
expect(del.status).toBe(200)
const view2 = (await (
await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/queue`)
).json()) as { length: number }
expect(view2.length).toBe(0)
ws.close()
})
itPty('drains the head into the PTY after a Stop hook (idle-drain)', async () => {
const { port, origin } = await spawnServer({ QUEUE_SETTLE_MS: '50' })
const { ws, sessionId } = await attachSession(port, origin)
await waitForQuiet(ws)
const enq = await postQueue(
port,
sessionId,
{ text: 'echo QUEUED_MARKER_A', appendEnter: true },
{ Origin: origin },
)
expect(enq.status).toBe(200)
const marker = waitForMessage(
ws,
(m) => m['type'] === 'output' && String(m['data']).includes('QUEUED_MARKER_A'),
)
const stop = await postStop(port, sessionId)
expect(stop.status).toBe(204)
await marker // the shell echoed the injected keystrokes → marker present
ws.close()
})
itPty('fires only one entry per idle (Stop) — the next waits for the next Stop', async () => {
const { port, origin } = await spawnServer({ QUEUE_SETTLE_MS: '50' })
const { ws, sessionId } = await attachSession(port, origin)
await waitForQuiet(ws)
await postQueue(port, sessionId, { text: 'echo MARK_ONE', appendEnter: true }, { Origin: origin })
await postQueue(port, sessionId, { text: 'echo MARK_TWO', appendEnter: true }, { Origin: origin })
// First Stop → only MARK_ONE drains.
const one = waitForMessage(ws, (m) => m['type'] === 'output' && String(m['data']).includes('MARK_ONE'))
await postStop(port, sessionId)
await one
// Without another Stop, MARK_TWO must NOT be injected.
const idleWindow = await collectOutput(ws, 500)
expect(idleWindow.includes('MARK_TWO')).toBe(false)
// A second Stop drains MARK_TWO.
await waitForQuiet(ws)
const two = waitForMessage(ws, (m) => m['type'] === 'output' && String(m['data']).includes('MARK_TWO'))
await postStop(port, sessionId)
await two
ws.close()
})
itPty('settle guard: a non-idle hook during the window cancels the drain', async () => {
const { port, origin } = await spawnServer({ QUEUE_SETTLE_MS: '300' })
const { ws, sessionId } = await attachSession(port, origin)
await waitForQuiet(ws)
await postQueue(
port,
sessionId,
{ text: 'echo SHOULD_NOT_RUN', appendEnter: true },
{ Origin: origin },
)
// Stop schedules a drain at +300ms; a PreToolUse immediately flips the status
// back to 'working', so the settle-time idle re-check skips the drain.
await postStop(port, sessionId)
await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }),
})
const window = await collectOutput(ws, 600) // past the 300ms settle
expect(window.includes('SHOULD_NOT_RUN')).toBe(false)
ws.close()
})
})

View File

@@ -25,6 +25,9 @@
*/
import net from 'node:net'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -103,6 +106,9 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 *
IDLE_TTL: '86400',
MAX_PAYLOAD_BYTES: String(maxPayloadBytes),
USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions
// W1: /open-in-editor spawns EDITOR_CMD. Use the POSIX no-op so tests never
// pop a real editor (and it's a non-goto basename → bare-file argv).
EDITOR_CMD: 'true',
})
}
@@ -403,6 +409,58 @@ describe('startServer — integration', () => {
}
}, 15_000)
// ── W1: POST /open-in-editor file mode + CSRF guard ────────────────────────
// EDITOR_CMD is 'true' (see makeTestConfig) so no real editor is launched.
// These don't need a PTY — they exercise the plain HTTP route + Origin guard.
it('W1: opens a FILE at a line with a valid Origin (204)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-file-'))
const file = path.join(dir, 'x.ts')
await fs.writeFile(file, 'hi')
try {
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
method: 'POST',
headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
body: JSON.stringify({ file, line: 3 }),
})
expect(res.status).toBe(204)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
it('W1: rejects file mode from a foreign Origin (403 — CSRF guard covers the new branch)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
method: 'POST',
headers: { 'content-type': 'application/json', Origin: 'http://evil.com' },
body: JSON.stringify({ file: '/tmp/x.ts', line: 1 }),
})
expect(res.status).toBe(403)
})
it('W1: still supports directory mode (regression — Projects panel intact)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-dir-'))
try {
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
method: 'POST',
headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
body: JSON.stringify({ path: dir }),
})
expect(res.status).toBe(204)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
it('W1: 404 for a file that does not exist (validated server-side)', async () => {
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
method: 'POST',
headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
body: JSON.stringify({ file: '/no/such/file/here.ts', line: 1 }),
})
expect(res.status).toBe(404)
})
// ── ⑤ attach → attached → output (needs real PTY — sandbox-off) ──────────
//
// NOTE (sandbox-off): This test spawns a real shell process via node-pty.
@@ -654,6 +712,74 @@ describe('startServer — integration', () => {
},
)
// ── W1: /hook/permission carries a bounded preview + re-sends to late joiners ─
itPty(
'W1 [needs real PTY (sandbox-off)] /hook/permission broadcasts a preview and re-sends it to a late joiner',
async () => {
const ws1 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws1, 3_000)
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws1,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
const sessionId = attached['sessionId'] as string
// Arm the pending-status listener on ws1 before firing (push is synchronous).
const pendingPromise = waitForMessage(
ws1,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
// Fire a Bash PermissionRequest carrying tool_input — HELD until approve.
const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo hi' } }),
})
const pending = (await pendingPromise) as Record<string, unknown>
expect(pending['status']).toBe('waiting')
const preview = pending['preview'] as Record<string, unknown> | undefined
expect(preview).toBeDefined()
expect(preview!['kind']).toBe('command')
expect(String(preview!['text'])).toContain('echo hi')
// Late joiner: a SECOND ws attaching to the same session while the approval
// is held must receive the preview on attach (re-sent like `gate`).
const ws2 = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws2, 3_000)
const joinPending = waitForMessage(
ws2,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
ws2.send(JSON.stringify({ type: 'attach', sessionId }))
const joined = (await joinPending) as Record<string, unknown>
const joinedPreview = joined['preview'] as Record<string, unknown> | undefined
expect(joinedPreview).toBeDefined()
expect(joinedPreview!['kind']).toBe('command')
expect(String(joinedPreview!['text'])).toContain('echo hi')
// Approve over ws1 to release the held POST, then clean up.
ws1.send(JSON.stringify({ type: 'approve' }))
await permPromise.then((r) => r.json()).catch(() => undefined)
ws1.close()
ws2.close()
await waitForClose(ws1, 3_000).catch(() => undefined)
await waitForClose(ws2, 3_000).catch(() => undefined)
},
)
// ── H1: tmux keepalive — a shell survives a full server restart ────────────
itTmux(
'H1 [needs tmux + real PTY] shell state survives a server restart',

Some files were not shown because too many files have changed in this diff Show More