Compare commits
18 Commits
e7bfbe951d
...
ca9eaa8f1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9eaa8f1f | ||
|
|
bc31de85dd | ||
|
|
469037cb94 | ||
|
|
9683a16f4f | ||
|
|
c81821b890 | ||
|
|
6541246fc9 | ||
|
|
a7eba2d43b | ||
|
|
19f241d7a3 | ||
|
|
552f35c690 | ||
|
|
1dd12b035a | ||
|
|
7551f8a4b2 | ||
|
|
b119c31019 | ||
|
|
3076843e9c | ||
|
|
e062065cd3 | ||
|
|
debf47d99e | ||
|
|
3e49e36806 | ||
|
|
09134e5001 | ||
|
|
8be2b06564 |
@@ -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 16–512 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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。")
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("选择一个项目查看详情。")
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
Text(
|
||||
text = "Diff",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
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))
|
||||
FilterChip(
|
||||
selected = !staged,
|
||||
onClick = { onSelectStaged(false) },
|
||||
label = { Text("Working") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = staged,
|
||||
onClick = { onSelectStaged(true) },
|
||||
label = { Text("Staged") },
|
||||
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("清除") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 项目专属指令。")
|
||||
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +222,21 @@ private fun ProjectCard(
|
||||
}
|
||||
}
|
||||
project.branch?.let { branch ->
|
||||
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) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 -> "操作完成"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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("没有"))
|
||||
}
|
||||
}
|
||||
@@ -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)**:全部阶段(v1→v2→v3)用多 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
160
docs/ROADMAP.md
Normal 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** ≈ 1–2 days · **M** ≈ 3–4 days · **L** ≈ 1–2 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. **S–M.**
|
||||
- [ ] **"While you were away" reconnect digest** — read-side aggregate over `manager.list()`
|
||||
+ telemetry/timeline/status → "3 done, 1 waiting, $6, 2 PRs" on reconnect. **S–M.**
|
||||
- [ ] **Recent-commits log per project** — `git log --oneline -n N` (NUL-delimited) via a
|
||||
guarded `GET /projects/log`; inert-text render. **S–M.**
|
||||
|
||||
---
|
||||
|
||||
## 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: S–M.** 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: M–L.**
|
||||
|
||||
---
|
||||
|
||||
## 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. **S–M.** |
|
||||
| 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. **M–L.** |
|
||||
| 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. **S–M.** |
|
||||
|
||||
---
|
||||
|
||||
_Kept in sync by the maintainer. Ideas are grounded against the code as of `develop` — verify
|
||||
file/line references before starting (they drift)._
|
||||
155
docs/plans/w1-approval-preview.md
Normal file
155
docs/plans/w1-approval-preview.md
Normal 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.5–2 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.
|
||||
143
docs/plans/w1-clickable-links.md
Normal file
143
docs/plans/w1-clickable-links.md
Normal 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, 45–47) 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:388–392`).
|
||||
|
||||
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 rel→abs 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 384–393**) 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).** RED→GREEN 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.5–2 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:155–159`), 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.
|
||||
138
docs/plans/w2-pty-inject-queue.md
Normal file
138
docs/plans/w2-pty-inject-queue.md
Normal 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:** ~2–3 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.5–1 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.
|
||||
131
docs/plans/w3-diff-vs-base.md
Normal file
131
docs/plans/w3-diff-vs-base.md
Normal 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.5–2 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
181
docs/plans/w3-pr-ci-chip.md
Normal 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.5–2 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
174
docs/plans/w3-quick-wins.md
Normal 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
|
||||
|
||||
**~3–4 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.5–0.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.
|
||||
135
docs/plans/w4-commit-push.md
Normal file
135
docs/plans/w4-commit-push.md
Normal 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:699–725`). 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 route’s `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 task’s 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:371–378`)
|
||||
|
||||
| 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:413–438`). 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 isn’t 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 task’s 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 file’s 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 7–8 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; git’s 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 can’t 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:74–76`).
|
||||
- **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**: ~3–4 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).
|
||||
147
docs/plans/w4-worktree-lifecycle.md
Normal file
147
docs/plans/w4-worktree-lifecycle.md
Normal 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 492–502) 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 67–69) 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 11–12, 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.5–2 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.
|
||||
155
docs/plans/w5-access-token.md
Normal file
155
docs/plans/w5-access-token.md
Normal 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 44–47 / 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 (`:1141–1146`) 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 `:44–47`). **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 (`:80–86`). (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.
|
||||
153
docs/plans/w5-android-parity.md
Normal file
153
docs/plans/w5-android-parity.md
Normal 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 (A1–A36) 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.
|
||||
157
docs/plans/w5-fanout-board.md
Normal file
157
docs/plans/w5-fanout-board.md
Normal 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 N−1 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, maxSessions−liveCount)`; 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 N−1 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 (~3–4 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.
|
||||
273
public/diff.ts
273
public/diff.ts
@@ -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
156
public/digest.ts
Normal 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
74
public/fanout.ts
Normal 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
262
public/gh-chip.ts
Normal 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
135
public/git-log.ts
Normal 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
91
public/link-paths.ts
Normal 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
125
public/login.html
Normal 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>
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
70
public/queue.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
343
public/style.css
343
public/style.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const TITLES = {
|
||||
'needs-input': 'Approval Needed',
|
||||
done: 'Task Complete',
|
||||
stuck: 'Task Stuck',
|
||||
budget: 'Cost Budget Reached',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
305
public/tabs.ts
305
public/tabs.ts
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
116
src/config.ts
116
src/config.ts
@@ -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 16–512 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,
|
||||
})
|
||||
}
|
||||
|
||||
263
src/http/approval-preview.ts
Normal file
263
src/http/approval-preview.ts
Normal 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
146
src/http/auth.ts
Normal 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
|
||||
}
|
||||
114
src/http/diff.ts
114
src/http/diff.ts
@@ -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
79
src/http/digest.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
316
src/http/gh.ts
Normal 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
109
src/http/git-log.ts
Normal 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
361
src/http/git-ops.ts
Normal 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
49
src/http/git-path.ts
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
57
src/http/session-groups.ts
Normal file
57
src/http/session-groups.ts
Normal 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)!)
|
||||
}
|
||||
@@ -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.' }
|
||||
}
|
||||
}
|
||||
|
||||
581
src/server.ts
581
src/server.ts
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
231
src/types.ts
231
src/types.ts
@@ -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 (16–512 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
139
test/auth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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: 'ctrl | ||||