diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a87b17e..757120eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,6 +208,21 @@ jobs: [ -n "$fpr" ] || { echo "::error::GPG_SIGNING_KEY did not import — is it truncated?"; exit 1; } echo "GPG_FPR=$fpr" >> "$GITHUB_ENV" + # The tagger address is READ FROM THE KEY, never hardcoded. It is not a preference: GitHub marks + # a tag Verified only when the tagger email, an email on the registered key's uid, and a verified + # account email all agree, so the only address that can possibly work is the one this key carries. + # Deriving it also keeps the address out of the repository — the project deliberately publishes no + # contact email anywhere (CHANGELOG 5.0.0) — and makes rotating the key sufficient on its own. + email=$(gpg --list-keys --with-colons "$fpr" \ + | awk -F: '/^uid:/ {print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p') + if [ -z "$email" ]; then + echo "::error::The CI signing key carries no email in its uid, so any tag signed with it will" + echo "::error::show as UNVERIFIED. This is exactly how v5.0.0 shipped. Regenerate the key with" + echo "::error::scripts/gen-ci-signing-key.sh and re-register it: scripts/bootstrap-ci.sh." + exit 1 + fi + echo "GPG_EMAIL=$email" >> "$GITHUB_ENV" + # Signed with the CI key, NOT the maintainer's YubiKey — which cannot sign inside a runner, and whose # non-exportability is exactly what makes it worth trusting. The chain still terminates in hardware # because the CI key is certified by it. The claims therefore shift, and SECURITY.md says so: the tag @@ -226,10 +241,17 @@ jobs: > /tmp/gpg-loopback chmod +x /tmp/gpg-loopback - # A bot identity, not a person: this tag is not a human's assertion and must not look like one. - # The noreply address is required by git and is not anyone's mailbox. - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' + # The tagger email is NOT a stylistic choice — it is what decides whether GitHub will ever show + # this tag as Verified. Marking a signature verified requires three things to agree: the tagger + # email, an email in a uid of the registered key, and a verified email on the account. This used + # to tag as `github-actions[bot]@users.noreply.github.com`, an address that can never appear on + # anyone's key, so the signature was valid and the badge was impossible. v5.0.0 shipped that way. + # + # It therefore uses the address carried by the signing key itself, resolved in the import step + # above. The identity is still unmistakable, because the key's uid names itself as the CI key and + # the signature is made by the CI key, not the hardware one — SECURITY.md keeps that distinction. + git config user.name 'Claude Code Native CI' + git config user.email "$GPG_EMAIL" git config gpg.program /tmp/gpg-loopback git config user.signingkey "$GPG_FPR" @@ -269,9 +291,23 @@ jobs: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.guard.outputs.tag }} run: | - # The newest section of RELEASE_NOTES.md: from the first "## v" heading to the next one. Same - # source build.gradle.kts reads for the Marketplace "What's New" panel, so they cannot drift. - awk '/^## v/{if(seen)exit; seen=1} seen' RELEASE_NOTES.md > /tmp/notes.md + # The newest section of CHANGELOG.md — from the first "## [x.y.z]" heading to the next one. + # + # It used to read RELEASE_NOTES.md, which is the Marketplace copy: emoji-led, second person, + # "one more thing". That register belongs on a storefront page where someone is deciding whether + # to install; it is the wrong document to hand a person who arrived at a GitHub Release because + # something broke and they need to know what changed. Those are different readers, so they now + # get different documents: CHANGELOG.md here, RELEASE_NOTES.md on the Marketplace panel that + # build.gradle.kts still feeds. + # + # Sized before switching: the 5.0.0 section is ~27 KB against GitHub's 125 000-character limit. + awk '/^## \[/{if(seen)exit; seen=1} seen' CHANGELOG.md > /tmp/notes.md + [ -s /tmp/notes.md ] || { + echo "::error::No section found in CHANGELOG.md. The heading format is '## [x.y.z] — date';" + echo "::error::if that changed, this extraction changed with it and the release would go out" + echo "::error::with empty notes rather than fail — which is why this check exists." + exit 1 + } # NB the heredoc body stays indented to this block's level: YAML strips the common indentation, # so the emitted markdown is flush-left. An unindented line here (a bare `---`, say) would end # the block scalar and be read as a YAML document separator. diff --git a/CHANGELOG.md b/CHANGELOG.md index c9bb58e0..74fd102f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,16 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [5.0.0] — 2026-08-05 -The standards-compliance major. Not a feature release: the repository was taken through the standards -catalogue domain by domain, and the major number reflects that the **code** changed to comply, not only the -documentation. The break this major records is one of *process and packaging*, and it is stated plainly rather -than hidden in a patch. +The standards-compliance major. The repository was taken through the standards catalogue domain by domain — +application security, licensing, accessibility, supply chain, release engineering, testing and static +analysis — and the major number reflects that the **code** changed to comply, not only the documentation: +**108 files, +9 699 / −3 431 lines**, of which roughly 4 100 are the JCEF front end. + +Compliance here is mechanised rather than asserted. Every claim this release makes is enforced by something +that fails a build: detekt and ktlint on Kotlin, ESLint and Prettier on the shipped JavaScript, per-package +coverage floors, a distributed-scope dependency audit, CodeQL on both languages, the plugin verifier across +the whole supported range with deprecated-API usage as a failure level, and artifact assertions that check +the published zip contains no npm code and does carry its third-party notices. It did not stay purely that, and saying so is cheaper than letting a reader discover it: the release also carries the **plan-limits panel** and a run of user-facing fixes (below). Nothing is removed or behaves @@ -74,6 +80,41 @@ card would be the kind of small untruth that makes the rest of the document unus - **A Markdown link whose href is a path did nothing when clicked.** The host handled `https://` and `jb://open` and dropped everything else without a sound — so `[BACKLOG](docs/BACKLOG.md)` was inert while bare paths written in prose worked, making the more deliberate link the one that failed. Both routes now go through a single authorising gate (`LinkResolver.isOpenable`). The scheme test requires **two or more** characters before the colon, so a Windows drive (`C:\src\main.kt`) stays a path rather than being mistaken for a URI scheme. - **Copy on a message copied and said nothing.** Message-level buttons carry their own click handler and never reached the delegated code-block path that flashes "Copied", which reads as a broken button — and was reported as one. The flash helper is now exported and shared rather than reimplemented, so wording and duration cannot drift. The `.copied` class had been applied by the JS since 4.0.4 and **had no CSS rule at all**; it now has one. +### Interface + +- **Onboarding: the plugin now installs and signs in Claude Code from inside the IDE.** A tab opened without the `claude` binary shows an install card instead of a loading screen that faded into an empty tab: one button per **official** install route for the current OS (Linux: install script, plus apt/dnf/apk when the distro is recognised; macOS: script and Homebrew; Windows: PowerShell, winget and cmd), each with the exact command it runs shown beside it, copyable — on a network that blocks one route, the command itself is the fallback. Commands run visibly in the IDE terminal; a manual path entry accepts a file or an install directory and is validated by running `--version` and requiring the answer to name Claude Code. Once the binary appears — by any route — the session starts on its own. +- **Sign-in lives in a card, not a command — and the credential does not live on your disk.** Signed out, the card is the first thing a tab shows, before a turn can fail on it. The subscription flow is fully native and requests the **full OAuth consent** — the reduced `setup-token` grant drops scopes Claude Code exercises, file upload among them, which is what a pasted attachment travels on: the binary's browser flow runs under a hidden PTY, the card shows the URL (copyable) and completes on its own when the browser finishes, so pasting the code is an optional fallback on the same screen rather than a step of its own. + That login normally leaves its credentials in `~/.claude/.credentials.json` — plaintext on Linux, readable by every process running as you, and shared with the terminal CLI. The plugin does not leave them there: they are moved into the **IDE's password safe** (OS keychain / KWallet / DPAPI) and the file is overwritten and deleted, a login made in your own terminal included. **Nothing ever writes that file back.** The credential reaches the binary through the process environment instead, which is narrower (`/proc//environ` is owner-only where the file was readable by anything running as you) and leaves nothing behind when the process exits. An orphan left by a hard IDE kill is folded back into the safe at the next launch. + + The binary keeps running against your own `~/.claude`, untouched, and it is handed the **whole** credential through the environment — access token, refresh token, **OAuth scopes**, subscription type, rate-limit tier and the account — not just the token. The scopes are the load-bearing part: the plan-limit windows come from an endpoint the binary only calls when the credential grants `user:profile`, so handing over a bare token left every session meter dark. That was misread during development as "the binary only reports this from its own configuration directory", and the fix attempted from that premise — a private configuration directory with your real configuration symlinked into it — **deleted the contents of the directories it linked to** when the session ended, session history included. That directory is gone, along with the recursive delete at its heart; the plugin now deletes exactly one file, ever: the plaintext credential it moves into the safe. A source-level contract test fails the build if any other deletion appears. + + An API key entered in the card goes to the same per-provider slot Settings uses, so the card and Settings ▸ Provider are one credential rather than two that disagree — and no provider's key can overwrite another's. A **valid key that the binary rejected** is fixed too: it requires each key to be approved once, and a `--print` session has nobody to ask, so the approval is recorded when you enter the key, and the key is verified before being stored at all. `claude auth status` validates whichever identity is effective and enriches the dashboard's account card, whose row always shows **Sign in** or **Log out** — and Log out stops the session first, then clears the IDE's copies, without touching your own terminal login. `/login` is no longer advertised in the palette — typed, it still works. + + Sign-in comes **before** the loading screen: verifying credentials needs no session, so an unauthenticated tab shows the card rather than launching a process to discover what it already knew. And all of it is re-checked continuously — installing the binary or signing in from elsewhere takes effect within seconds, with no tab to close and reopen. + +- **Plan-limit bars** in the session dashboard and a matching dot in the composer readout: every rate-limit window plus the extra-credit balance, colour-graded by severity, animating to their value so the number and the bar settle together. Each source is read on the scale it actually uses — the live events carry a `0..1` fraction, the on-demand usage reply `0..100`. + +- **The chat is reachable only while Claude Code is running.** Install → sign in → loading → chat, and any step backwards — the binary uninstalled, the credential gone, the process exited — stops the session and returns to the matching screen. The loading screen waits for the binary to answer rather than merely to start, so the first frame is drawn with the command list, model catalogue and account already in hand. +- **A unified entrance for every transcript row.** Messages, tool cards, thinking folds, recalled-memory folds, elicitation cards and notices all rise into place on the same curve; a completed tool call resolves with a single 1.5% beat, sized to register at the edge of vision rather than to be watched. +- **A boot overlay** covering the interval between launching the binary and the session being ready, with a distinct state for a launch that failed. +- **Reduced motion is driven by the IDE**, not by the browser's own media query, so it follows the setting the user actually changed. Its handling is explicit rather than a blanket freeze: looping indicators keep a legible resting state instead of stopping on their first frame. +- Failed tool output wraps instead of scrolling sideways; Copy affordances share one confirmation state; the loading indicator and empty state use the same Claude glyph, drawn as a character rather than an asset so the hash-pinned CSP is unaffected. + +### Release integrity + +- **Release tags are cryptographically verifiable.** The CI signing key carries an email identity, is registered on the publishing account, and is certified by the maintainer's hardware key; the workflow derives the tagger address from the key it signs with, so key rotation is self-contained and the two cannot fall out of step. The address is never written into a committed file. A key without an email identity now aborts the release. +- **The tag precedes the artifact.** `publish` cuts and signs the tag, checks it out, and builds from that ref, so the published bytes correspond to the ref that names them. Re-running the job on an existing tag is idempotent and replaces the assets in place, which makes recovery from a failed publish a re-run rather than a manual intervention against an immutable tag. +- **Publication is a single reviewed act.** Merging the release pull request into `main` publishes; credentials remain scoped to the `marketplace` environment and unreachable from any other job. `scripts/bootstrap-ci.sh` provisions the environment, both deployment refs, all six secrets, the signing key and its account registration in one idempotent run. +- **The GitHub Release carries `CHANGELOG.md`**; the Marketplace "What's New" panel continues to carry `RELEASE_NOTES.md`. An empty extraction fails the release. + +### Continuous integration + +- **Segmented CI images**: `node-test` (462 MB) for the npm jobs, `jvm-test` (8.08 GB) for the Gradle jobs, with the artifact-assertion and release-readiness jobs on bare runners. Container startup for the frontend suite is **10 s**, from 5m37s. The plugin verifier's IDEs are resolved at run time rather than baked, keeping them current with the EAP/RC channels they come from. +- **The Gradle cache in the image is genuinely warm**: the IntelliJ Platform is extracted at image-build time and the build is verified to compile **offline** from it. A warm-up failure fails the image build. +- **CodeQL is a required check on `develop` as well as `main`**, with the `java-kotlin` analysis running on the same JDK and warm cache as the rest of the pipeline. +- **A release-readiness gate** blocks `develop → main` while an automated pull request is open against `develop`, so a release cannot ship alongside an unmerged dependency update. +- Branch protection, deployment policy and required checks are versioned and applied by script. + ## [4.4.1] — 2026-07-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index ce357d6a..1a81eb8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ claude --print --output-format stream-json --input-format stream-json --verbose **Who writes the file:** on `allow`, **the binary writes** (not the IDE). Therefore, before approving Edit/Write we reconstruct the proposed content and open a **diff in an editor tab** (`SimpleDiffRequest`→`ChainDiffVirtualFile`→`FileEditorManager.openFile`; NOT `DiffEditorTabFilesManager.showDiffFile`, which opens a window). Approval = **inline non-modal card** (Accept/Reject/View diff), never dialogs. After writing, refresh VFS (`VfsUtil.markDirtyAndRefresh`). ## Architecture (`src/main/kotlin/dev/lain/claudejb/`) -- `process/` — `ClaudeBinaryLocator` (locate/validate, cross-platform incl. Windows) + `ClaudeProcess` (GeneralCommandLine + KillableColoredProcessHandler, stdio, graceful kill) + `EnvScriptLoader` (sources a shell script to seed the process env). +- `process/` — `ClaudeBinaryLocator` (locate/validate, cross-platform incl. Windows) + `ClaudeProcess` (GeneralCommandLine + KillableColoredProcessHandler, stdio, graceful kill) + `EnvScriptLoader` (sources a shell script to seed the process env) + `BinaryInstall` (the "Claude Code was not found" card's catalogue of OFFICIAL per-OS install routes — Linux script + apt/dnf/apk by distro detection, macOS script + brew, Windows ps1 + winget + cmd — plus `validate()`, which accepts a file OR a dir and requires `--version` to name "Claude Code"; commands mirror code.claude.com/docs/en/setup verbatim) + `AuthCli` (`auth status --json` → `{loggedIn,email,subscriptionType}` and `auth logout`; what makes login detection PROACTIVE; must be passed `effectiveLaunchEnv()` or a safe-held token reads as logged-out) + `ClaudeLoginFlow` (pty4j; argv-parameterized — `auth login` or `setup-token` — with `onToken` fired off the PTY stream) + `LoginOutputParser` (pure; also `extractSetupToken`, and `resultMessage` MASKS any `sk-ant-…` so a token can never ride into a notification) + `TerminalLauncher`. - `protocol/` — kotlinx.serialization models + `ProtocolParser` (NDJSON→`ClaudeEvent`) + `ControlProtocol` (output builders). Lenient decoding (ignoreUnknownKeys). - `session/` — `ClaudeSession` is a **thin orchestrator** (one per tab): owns `process`, `session_id`, the multiprompt `queue`/`send`/`pump`, the observable `transcript`, `ready`, listeners (`fireState`/`fireMetadata`/`firePermissions`/`fireAttention`/`fireTitleChanged`), the `edt {}` dispatcher, the `broker`, and the `onEvent` **dispatch** that routes each `ClaudeEvent` to a collaborator. It **delegates** to single-responsibility collaborators (refactored out 2026-06-03 so each 3.0.0 epic edits one file, enabling parallel work): - `SessionLauncher` (object) — `buildArgs`/`mcpConfigJson`/`resolveStdioParams`/`findMcpServerLib`/`binaryPermissionMode`, from an immutable `LaunchOptions` snapshot. @@ -35,7 +35,7 @@ claude --print --output-format stream-json --input-format stream-json --verbose - `PermissionCardManager(onChanged)` — the EDT-confined pending permission-card queue (`present`/`remove`/`all`/`clear`). - `HookBroker` — host-side hook decisions (E3): parses `hook_callback`, returns `HookJSONOutput`, exposes side-effects (NotifyUser/RefreshFile/TranscriptNote) for the session to apply. - `HookActivityNarrator(transcript)` — narrates the binary's hook *telemetry* (`hook_started`/`hook_progress`/`hook_response`) as ONE evolving transcript row per hook id (distinct from `HookBroker`, which answers the `hook_callback` control request). Cleared on stop/terminate. - - `LoginCoordinator(project, edt, notifyInfo, notifyError, notifyMissingBinary, restartSession)` — the whole OAuth sign-in subsystem (5.0.0), which has nothing to do with running a turn: the TTY-less `--print` session can't host an interactive login, so it happens outside the session through three ordered paths (IDE terminal → native PTY `ClaudeLoginFlow` → manual notice), and owns the `prompted`/`flow`/`authUrl` state that went with them. `ClaudeSession.startLogin()` is now a one-line delegate; `onEvent` calls `login.maybePrompt()` / `login.onCleanResult()`. + - `LoginCoordinator(project, edt, notifyInfo, notifyError, notifyMissingBinary, restartSession)` — the whole OAuth sign-in subsystem, which has nothing to do with running a turn: the TTY-less `--print` session can't host an interactive login, so it happens outside the session. Since the onboarding rework the PRIMARY path is **card-driven and fully native**: `ClaudeLoginFlow` runs `claude auth login` under a PTY (NOT `setup-token` — its reduced grant drops scopes Claude Code exercises, file upload among them), driven by the JCEF sign-in card through the `LoginUi` seam (`attachUi`/`detachUi`, implemented by `OnboardingController`). The card is ONE browser step: the binary opens the browser itself (the host must NOT also call `BrowserUtil.browse` — that opened a second tab) and captures the callback, so the code field is an optional fallback on the same screen; `submitCode` writes `\r`, not `\n`, because the Ink TUI is in raw mode and only `\r` submits (an `\n` hung the card on "Verifying"), with a 45s watchdog behind it. **`CredentialsVault` is what keeps the credential off the disk**: `auth login` writes `~/.claude/.credentials.json` (plaintext on Linux, shared with the terminal CLI), so the vault harvests it into `SecretStore.CREDENTIALS_JSON` (the IDE **PasswordSafe**) and DELETES it — including a login made in the user's own terminal, deliberately. **Nothing ever writes that file back** (`materialize` was removed): the credential reaches the binary as `CLAUDE_CODE_OAUTH_TOKEN` via `CredentialsVault.envOverlay`, which takes precedence over the binary's own store (verified on 2.1.223 — `auth status` flips `authMethod` from `claude.ai` to `oauth_token`). Two consequences, both real and both accepted: (1) only the binary can spend the refresh token, and it does so by rewriting that file, so an expired token is simply **not an identity** (`hasUsableToken()` false → sign-in card) rather than a session that fails its first turn; (2) the `oauth_token` identity is REDUCED — `auth status` then returns `authMethod`/`apiProvider` and no email or plan — so the dashboard's plan falls back to `CredentialsVault.subscriptionType()` off the vaulted blob, and the email is genuinely unavailable by this route. `harvest()` runs at `launch()`, at `stop()`, and in the boot watcher, but NEVER while `LoginCoordinator.inProgress` — `auth login` finishes by writing that very file, and harvesting mid-flow deleted it under the binary and broke the browser leg, leaving code-paste as the only route that worked. `CredentialsVault.homeOverride`/`inertHere()` make the vault refuse to touch a real home from a test JVM: the integration tests start a real session, whose `launch()` harvested the developer's own credentials into a throwaway test safe and deleted them — invisible while the file was still written back, destructive the moment it wasn't. `CREDENTIALS_JSON` is file-shaped and deliberately NOT in `SecretStore.envOverlay`. The **API key is not in `SecretStore` at all**: it lives in its own provider slot (`ClaudeSettings.setProviderApiKey(Provider.ANTHROPIC, …)` → `providerApiKey:anthropic`), the same mechanism DeepSeek uses, so the card and Settings ▸ Provider are two doors onto one credential and no provider's key can overwrite another's; `effectiveLaunchEnv()` applies it only when the selected provider IS Anthropic. Credentials are env only, NEVER argv, never logs/transcript/XML. **`ApiKeyApproval`** closes the bug that made a valid key look invalid: the binary demands each `ANTHROPIC_API_KEY` be approved once and records the last 20 chars in `~/.claude.json` → `customApiKeyResponses.approved`; under `--print` there is nobody to ask, so an unapproved key is refused. Typing it into the card IS that answer, so the approval is written (amending the file, never replacing it) and the key is then validated with `AuthCli.status` before being stored at all. Fallbacks in order: IDE terminal (`auth login`) → dialog-driven PTY → manual notice. `ClaudeSession.needsLogin` raises the card proactively and reactively (`LoginDetection` on failed turns / auth errors) — and the plugin's auth identity is **exclusively** what it holds securely (`SecretStore` or an explicit Settings env var): with nothing held, the state is logged-out BY DEFINITION, no probe run, because the binary would answer from the terminal CLI's own store, a separate identity the plugin never reads. **Sign-in precedes the boot screen**: `start()` refuses to launch without a credential (`hasCredential`) — verifying auth needs no session, and launching one we know is unauthenticated buys a spawned process and a turn that fails for a reason already known at click time. And the three boot states are RE-DERIVED, not remembered: `refreshBootState()` runs every 3s off-EDT while no session is up, so installing the binary or signing in outside the card takes effect without closing the tab (detection used to happen once, inside `start()`, which is why a tab opened before the install kept its stale answer forever). A held credential is validated via `AuthCli.status` (run with `effectiveLaunchEnv()`, or a safe-held token reads as logged-out). `/login` is no longer advertised in the palette (sign-in is a BUTTON on the card and the dashboard account row, which also offers Log out = **the safe cleared, nothing else** — `auth logout` from an IDE button would kill the user's terminal login), but a typed `/login` still works as a silent alias; the card's skip button is labelled as consent to ride the terminal's login for the session. - Pure formatters: `MemoryRecallFormatter` (`memory_recall` → header + markdown body), `StatusLineFormatter` (live `thinking_tokens` → bucketed status suffix), and `protocol/DialogResponder` (the `{behavior:"cancelled"}` reply + transcript note for `request_user_dialog`). Plus `ChatSessionManager` (`@Service(PROJECT)`, owns the tabs) and the session-history readers (`SessionStore`/`SessionTitleReader`/`SessionTranscriptReader`/`SessionHistory`). **Rule for new work: add behaviour to the right collaborator (or a new one), keep `ClaudeSession` a delegating orchestrator, never re-grow the god-object.** - `settings/ClaudeSettings` — `@Service(PROJECT)` `PersistentStateComponent` (`claude-code.xml`): persists model·effort·permissionMode·thinking·allowed/disallowedTools·settingSources·claude/nodePath·envVars·sourceScript. `applyTo(session)` seeds launch options before `start()`. @@ -64,7 +64,7 @@ Build: `JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew buildPlugin` → zip in `build/d **Guideline — always latest, zero deprecations:** keep platform/Gradle/Kotlin/deps on the newest stable, widen `untilBuild` to the current EAP/RC, and **never ship a deprecated or scheduled-for-removal API**. If `verifyPlugin` flags one, migrate it before release — treat it as a blocker, not a warning. Everything up to date, always. ## Status -Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, name **"Claude Code Native"**, version **5.0.0**, compatibility **251 → latest EAP/RC** (compiled against IC 2025.2; floor lowered from 252 in 4.3.1 — 251 is as far back as the API reaches with ZERO deprecations: `FileChooserDescriptorFactory.multiFiles()/singleDir()` in `FilePickerHelper` does not exist on 242/243, and its pre-251 equivalent is deprecated on current IDEs. Verified offline against locally-extracted IDEs via `-PlocalIdePath=[,…]`, which now takes a comma-separated list). **5.0.0 = the standards-compliance major.** The repository was put through the standards catalogue domain by domain, and the major reflects that the *code* changed, not just the docs. It did NOT stay purely that: it also ships the **plan-limits panel** (`get_usage`, a control request known since 4.0.1 and never sent — all rate-limit windows plus the extra-credit balance, as dashboard bars and composer dots, blue <65% / amber <85% / red above, announced once per threshold per window) and a run of user-facing fixes, the largest being a **tab-killing NPE this branch itself introduced**: `JcefChatPanel.pendingUntilReady` was declared BELOW the `init` block that uses it, and Kotlin runs property initializers and `init` blocks in declaration order — so it was null inside the constructor and NO chat could be opened or restored. `lastUsage`/`lastUsageAt` had the same defect and stayed silent (nullable/primitive read as null/0), which is why `InitOrderContractTest` now scans the sources: the compiler only flags a *direct* reference in an initializer, not one made through a function called from `init`. Also from that pass: a **boot screen** (the binary is launched BEFORE the tab is built, since `start()` only dispatches; three states — running / starting / **neither**, that last being a failed launch which MUST clear the screen); context and cost polled on ready, tab-open and both turn edges instead of waiting out a `javax.swing.Timer` whose initial delay equals its 60s interval (and the timer now retires at turn end — those numbers cannot move while idle); the CLI's `` wrapper stripped in `ProtocolParser.unwrapToolError` (verified in 2.1.222, which carries the same text unwrapped in a sibling field — rendering it verbatim put raw markup in a native GUI); failed tool cards auto-open once and wrap their error text (collapsed, the whole message was "the header is red"); `ToolSearch` + `AskUserQuestion`/`Mcp`/`FileRead`/`FileEdit`/`FileWrite` added to `SensitiveGuard.AGENT_TOOLS` — **that list is only ever appended to**, it is a trust allowlist and not an inventory, and `ToolSearch` was the load-bearing gap (it loads every deferred tool's schema, so on a session that defers them the call unlocking all the others was landing in the untrusted branch); and markdown links whose href is a path now open (`LinkResolver.isFilePathHref`, with a two-or-more-character scheme test so a Windows drive stays a path) through the same `isOpenable` gate as `jb://`. (1) **Dependency scope corrected** — `@anthropic-ai/claude-agent-sdk` sat in `dependencies` while it is protocol reference only, producing 7 permanent npm-audit findings (3 high) against code no user receives; moved to `devDependencies` (`npm audit --omit=dev` → 0), `checkDrift` verified green across the move (it reads the SDK from `node_modules` and runs `npm update`; only `--omit=dev` would break it) and re-baselined to `claude` 2.1.222 / SDK 0.3.222. `package.json` also declared `"license": "ISC"` on a GPL-3.0-only repo and was missing `"private": true` — i.e. publishable to npm under the wrong licence. (2) **`LoginCoordinator` extracted** from `ClaudeSession` (1965 → 1826 lines) — the OAuth subsystem and its three state fields; mechanical, no behaviour change, 677 tests green across it. The other two extractions the plan proposed (`SessionRestorer`, `RewindCoordinator`) were **deliberately not done**: `restore` is 23 lines that write six pieces of session state, and rewind is one of six identically-shaped `controlClient.query` delegates — extracting either buys indirection, not cohesion. (3) **Accessibility** — WCAG 4.1.3 live region (`#a11y-status`, declared in the static shell so the first write is announced) + `CC.announce`, and a `:focus-visible` baseline with a `forced-colors` fallback, pinned by 10 frontend tests; the EU Accessibility Act has applied since 28-jun-2025. (4) **Attribution ships inside the artifact** (`THIRD-PARTY-NOTICES.md`, `LICENSE`, `LICENSES/*` under `META-INF/`) — a permissive licence's notice obligation binds on *redistribution*, and the plugin redistributes marked/DOMPurify/highlight.js. (5) **Governance**: commitlint + a versioned `.githooks/commit-msg` that degrades to advisory if the toolchain fails (so it never becomes a reason to reach for `--no-verify`), `.gitattributes`, and three ADRs — [0001](docs/adr/0001-release-process.md) release process (GitFlow and GPG-on-YubiKey as *recorded deviations*, tag immutability as a **correction**: `v4.3.2` and `v4.4.1` were each force-re-cut three times, which is exactly what a signature is supposed to prevent; plus the generated-CHANGELOG deferral with a one-command exit test), [0002](docs/adr/0002-threat-model.md) threat model (trust model + STRIDE over binary/MCP/model-content; prompt injection is **assumed to succeed**, not detected), [0003](docs/adr/0003-i18n-deferred.md) i18n deferred with its triggers. **4.4.1 = `/login` terminal launch fixed (REAL regression, silent).** Every platform API `TerminalLauncher` reflected on was missing at runtime: the Reworked path looked up `com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager`, which is NOT in the shipped IDE at all (scanned every jar of IU-262.8665.337), and the Classic path used `TerminalToolWindowManager.createShellWidget(…)`/`.createLocalShellWidget(…)`, present on 251/252 but REMOVED by 262. Each lookup returns false rather than throwing → totally silent, nothing in idea.log, `/login` always landed on the "run it yourself" notice. Fix: `createNewSession(workingDirectory, tabName, shellCommand, requestFocus, deferSessionStartUntilUiShown)`, verified by hand on 251+252+262, with the login passed as **argv** (`TerminalLauncher.loginArgv`) not a shell string — killing the quoting hazard (Windows `&` prefix, spaces) and the send-into-a-shell race at once. **Why CI missed it:** the plugin compiles/tests against IC-2025.2, where the removed factories still exist — the break only manifests at 262+, so `TerminalApiContractTest` pins the replacement against the build classpath and `verifyPlugin`'s range run is the complementary half. Also wired `ClaudeLoginFlow` (pty4j) in as a REAL fallback — it was unreachable code, since `startLogin()` called the terminal unconditionally — so order is now terminal → native PTY → manual notice; and fixed a latent bug there: pty4j REPLACES the child env wholesale (unlike `ClaudeProcess`, which inherits via `withParentEnvironmentType(CONSOLE)`), so `System.getenv()` must be merged in or the spawned binary loses `PATH`/`HOME`. **4.4.0 = per-rule security toggles + `AGENT_TOOLS` allowlist fix.** Each `SensitiveGuard` rule (CREDENTIAL, DANGEROUS_COMMAND, and FOREIGN split into its three sub-rules via `ForeignReason`) is independently switchable via five `Policy.enforce*` fields ← `ClaudeSettings.securityBlock*` ← Settings ▸ Claude Code ▸ Security; all default true = the original hard lock. Detection (`classify()`) runs UNCONDITIONALLY — a toggle only downgrades the OUTCOME `DENY`→`ASK` (for every caller, MCP/Skills included), never to ALLOW, so a disabled rule is still a card every time. `reason()` always names the Settings path. `AGENT_TOOLS` had gone stale as the CLI grew its own orchestration surface (`Task*`, `Cron*`, worktrees, `Agent`, `SendMessage`, MCP-resource tools…), so those FIRST-PARTY calls fell into the untrusted branch and were hard-DENIED like a blocked MCP server; rebuilt from the vendored SDK's `ToolInputSchemas`, with `Skill`/`mcp__*` still deliberately excluded. NB FOREIGN denies regardless of caller trust by design, so the allowlist fix only changes CREDENTIAL/DANGEROUS_COMMAND outcomes. **4.3.3 = model-picker autodetect + Opus pinned as default.** The picker was ALREADY autodetected from the `initialize` catalog, but it labelled entries with the binary's `displayName`, which omits the version ("Opus (1M context)", "Sonnet") — so Opus 4.8 vs Opus 5 was indistinguishable. The version lives in `description` ("Opus 5 with 1M context · …"), so `JcefState.modelDisplayLabel` now prefers that description head (→ `displayName` → `deriveModelLabel(id)`), and BOTH selectors (composer pill/menu + the Settings combo renderer) share it so they can't disagree. The binary lists a floating `default` alias AND the concrete `opus[1m]` it resolves to — the same model twice, the alias with no version — so `default` is filtered out of both lists (`ClaudeSession.RECOMMENDED_ALIAS`) and `DEFAULT_MODEL` is now the CONCRETE `opus[1m]` (was `"default"`), pinning Opus even if the binary re-points its recommendation. `ClaudeSession.preferredDefault(models)` is the graceful fallback (pin → binary's recommended alias → first listed), so we never select a model the binary doesn't offer; a legacy persisted `"default"` migrates on display (`reset()`) and via `changeModel`. Also killed a hardcoded `"Default · Opus 4.8"` pill literal that had gone stale the moment the recommended tier became Opus 5 — no version is baked in anywhere now. Re-baselined to `claude` 2.1.220 / SDK 0.3.220 (`checkDrift` green, protocol surface unchanged). **4.3.2 (re-cut) = command code block + syntax highlighting + two SensitiveGuard false-triggers.** The executed command renders as its own copyable code block in `.tool-cmd` — a SIBLING of `.tool-out`, so it's visible WITHOUT expanding the card (only the output stays behind the collapse toggle) — the header shows just the tool name (no raw-command churro), and the card gets a `cmd-tool` left accent. Detection is by input SHAPE, not tool name (`SensitiveGuard.commandText`/`isCommandCall` → `TranscriptEntry.commandText` → `JcefBridge` `command` field), so Bash, PowerShell and any MCP exec tool are covered by one rule that can't drift from the security rules it shares. Diffs and Read/Write/Edit output are syntax-highlighted from the file extension (`CC.languageForPath` → ~35 langs in the vendored hljs bundle; hljs autodetection as fallback), layered under the existing add/remove diff colouring. The two security fixes were REAL false-triggers found live: `isUnc()` flagged ANY `//`-prefixed string — including an ordinary `// comment` line inside an `Edit`'s `old_string` (`pathCandidates` walks every string leaf) — as a UNC share, i.e. FOREIGN, which hard-DENIES regardless of caller trust, so editing a commented line could be silently refused with no override; fixed by requiring the post-`//` host segment to be non-blank and whitespace-free. And `substituteAssignments` passed a shell-assigned value straight to `String.replace(Regex, String)`, which treats it as a REPLACEMENT TEMPLATE — a value containing `$`/`${…}` threw an uncaught `IllegalArgumentException: Illegal group reference` (confirmed via idea.log stack trace), crashing `verdict()` and leaving that `can_use_tool` unanswered; fixed with `Matcher.quoteReplacement`. **4.3.2 = WSL `/mnt/c` fix.** WSL2 surfaces the Windows `C:` drive over 9p (in `RemoteMounts.REMOTE_FS_TYPES`), so `detect()` put `/mnt/c` in `remoteRoots` and the startup gate (`RemoteMounts.isRemote`) refused to launch on a normal `C:\` project (and the same `remoteRoots` fed `SensitiveGuard`'s foreign rule). Fixed two layers: `detect()` drops all `/mnt/*` from `remoteRoots` under WSL (governed by the dedicated `/mnt/c` rule), and `isRemote` exempts `/mnt/c` before the fstype checks. **4.3.1 = deterministic sensitive-data lock (`permission/SensitiveGuard`, `session/RemoteMounts`) + jump-to-code + the chat-focus fix + live VFS refresh.** The sensitive-data lock intercepts every `can_use_tool` in `PermissionBroker.handle` before any auto-approval (so it holds in bypass/acceptEdits): credential/key globs (structural, cross-OS incl. WSL) + dangerous-command regexes (after path canonicalisation + shell de-obfuscation) + foreign territory (other user's home, network/UNC mount, non-`/mnt/c` WSL drive); agent tools ASK, MCP/Skills DENY, foreign DENY-for-all, no opt-out; project root exempt; won't start on a remote-mounted project. Validated live (native Read of `~/.claude/.credentials.json` → card in bypass; MCP → denied). Jump-to-code links in the transcript: a file tool card names its file PROJECT-RELATIVE and links it (`ClaudeSession.toolFilePath` → `TranscriptEntry.filePath` → `renderToolLabel`), and paths/dirs/symbols in model text are linked only after the host CONFIRMS them (`ui/LinkResolver.kt`: file index → Go-to-Symbol EP → bounded on-disk scan for excluded dirs like `build/`; unambiguous matches only, so no dead/misleading links). Security: `LinkResolver.isOpenable` (project OR $HOME, canonical, symlink-safe) gates opening — the WRITE gate (`DiffPresenter.isWithinRoot` in `PermissionBroker`/`FileRollback`) stays project-only. **The focus bug** that made a new tab unusable was NOT the JCEF bridge (three wrong hypotheses before the log settled it): the tab never declared `Content.preferredFocusedComponent` (and it must point at `cefBrowser.uiComponent` — `JBCefBrowser.getComponent()` is a non-focusable wrapper), and a raw `requestFocusInWindow()` is REFUSED while `IdeFocusManager` settles focus (measured: denied 34×). Fix = `setSelectedContent(content, requestFocus = true)` (the ContentManager transfers focus as part of the selection, the same path a manual tab switch takes) + telling CEF it has focus in `JcefHost.markWebReady()` — i.e. once the page EXISTS, since a freshly loaded page starts with its focus flag cleared and paints no caret. **VFS refresh is now per-write, not per-turn** (`ClaudeSession` ToolResult → `DiffLifecycleManager.refreshTouched()` for the exact paths + `refreshProjectTree()` when `mayHaveWrittenUnknownFiles(tool)` — Bash or a mutating MCP tool); `refreshTouched` also refreshes the PARENT dir, because refreshing a file the VFS has never heard of is a no-op and a newly CREATED file stayed invisible. NB `PluginId.getId(…)` is banned: `PluginId` is a Kotlin class since 2025.2, so it binds to `PluginId.Companion` and dies with `NoSuchFieldError` on any IDE below 252 — use `util/InstalledPlugins.kt` (id from the descriptor). **4.2.0 was a protocol-upgrade + dashboard release** — re-baselined to `claude` 2.1.204 / SDK 0.3.204: models `system/background_tasks_changed` (a **level** signal — the binary re-sends the FULL live background-task set on every membership change; tracked in `TaskTracker.backgroundTasks` with REPLACE semantics, kept **deliberately uncorrelated** with the edge-derived `subagentTasks` because the SDK leaves their relative ordering unspecified, and reset per-process in `clear()`) and surfaces it as a **"Background tasks"** dashboard card with Stop (`JcefSessionData.backgroundTasksJson` + `app-session.js buildBackgroundTasksCard`) — unlike the edge-derived Subagents list it can never wedge a stale "running" indicator; also models `system/control_request_progress` (progress for a host-originated control request, currently `side_question`/`/btw`: an `api_retry` status carries the same counters as `system/api_retry` and is surfaced the same way, `started` goes to debug). Triages the thin-client host→binary control requests the plugin knowingly never sends — `list_models` (the model catalog comes from the `initialize` reply), `get_plan`, `get_workspace_diff` — into `ProtocolSurface.KNOWN_SUBTYPES`. `./gradlew checkDrift` green at the new baseline. **4.1.0 adds editable diff review for edits:** when Claude asks to Edit/Write/MultiEdit, the plugin auto-opens an **editable** diff in the IDE editor (Current | Proposed, proposed side via `DiffContentFactory.createEditable`) on the permission request — not just in acceptEdits/bypass; the user can **tweak the proposed content** before accepting, **Accept writes their edited version** (`HunkSelection.encodeInput` re-encodes the tool input; fail-safe to the original proposal when unchanged/read-only), the captured snapshot is repointed at the effective input so the transcript inline diff + "View diff" show the **real** written change, and the diff closes on accept/reject/stop/interrupt (`DiffPresenter.openReviewDiff` + `DiffLifecycleManager` review-diff registry + `EditSnapshotStore.updateInput`). **4.0.5** replaced the permission card's per-hunk checkboxes with a **read-only colour diff** (per-line partial accept produced incoherent/broken edits; edits are now atomic — accept/reject the whole change). **4.0.4 (branch `bugfix/various-fixes`) is a broad bug-fix + UX pass:** the **interrupt** now actually stops the turn (correlated control request clears `turnActive`; transient "Interrupting…" on the Stop button via a `session.interrupting` flag; queue + pending permission cards flushed) instead of looping the "Interrupting…" notice forever; **first-open dead chat** is self-healed (the web app retries `ready` until `window.__ccSend` exists, and `JcefHost` reloads via `loadHTML` if the page doesn't come alive — kills the "reopen the tab" workaround); **user prompts render verbatim** (`buildUser()` is `kind:'text'`, never Markdown); the code-block **Copy** button works (a delegated `document` handler replaced the listener lost on `innerHTML` serialization); duplicate/out-of-order **"Thought process"** fixed in `TranscriptReconciler` (a `settledThinking` pointer finalize-replaces the streamed entry); **menu flicker/de-selection during streaming** fixed (incremental `renderState`, open menu rebuilt only when its selection changed; `JcefChatPanel.onAdded` no longer forces a full structural re-serialization for tail appends — was O(N²)); single ✓ in prompt menus; Esc on the find bar no longer also interrupts; **"Always allow"** resolves the exact card (carries the `requestId`, not first-by-tool-name) and a **zero-hunk accept is a deny**; permission re-push reconciles by `card.id` (no wiped elicitation/question/hunk input); the session **dashboard** lays out (`.dash-inner` grid, hides `#conversation` while open) without covering the composer; **clipboard paste runs off-EDT** with a deadline (no IDE freeze on a hung Wayland clipboard); the **find bar** scrolls to the active hit + Enter/Shift+Enter navigation (`i / n`); **adaptive thinking is on by default** (`ClaudeSettings.thinkingTokens = THINKING_ON`); faster Vibe Mode rainbow; **responsive** composer (pills wrap) / find / chips + truncated tab titles (full title in tooltip). Latent fixes: a `starting` guard + generation re-checks prevent a double `claude` spawn / mid-launch orphan, `dispose()` bumps the generation (no spurious "exited unexpectedly"), a malformed `can_use_tool` can't throw+hang the turn (replies error), and `ClaudeToolWindowFactory` resolves its tool window per-project (no shared-state cross-project bug). **Protocol re-baselined to `claude` 2.1.193 / SDK 0.3.193** — models `system/informational`·`model_refusal_no_fallback`·`worker_shutting_down`; `./gradlew checkDrift` green. **4.0.3 fixed composer clipboard paste on native-Wayland IDEs** — under `sun.awt.wl.WLToolkit` the embedded CEF browser's web clipboard is isolated from the system clipboard, so the composer's `paste` event never reached the host. `JcefState.metaJson` now emits a `hostClipboard` flag (true under the Wayland toolkit) and `app-composer.js` routes `Ctrl+V` straight to the host, which reads the real clipboard via `wl-paste`/`xclip` (the path the Attach→Image button already used). 4.0.2 had added that host-side `wl-paste`/`xclip` *read* fallback (`EditorContextProvider.clipboardText`/`clipboardHasText`, guarded by the pure `preferredTextType`) but it was never reached — the bug was the trigger, not the read (AWT/`CopyPasteManager` *reads* are broken on native Wayland; *writes* work). **4.0.1 is a protocol-upgrade release** — re-baselined to `claude` 2.1.170 / SDK 0.3.170: models the new `system/model_refusal_fallback` message (primary model refuses → turn retried on a fallback model; surfaced as a transcript notice) and triages the new `get_usage`/`register_repo_root`/`reload_skills` host→binary control requests into `ProtocolSurface.KNOWN_SUBTYPES`, so `./gradlew checkDrift` is green again. **4.0.0 rebuilds the entire chat UI on JCEF** (embedded Chromium web view — modern streaming transcript, web composer, native permission/question/elicitation cards, and a session dashboard; see "## JCEF UI (4.0.0)" above), and **deletes the old Swing chat UI** (`ChatPanel`/`TranscriptView`/`ChatMessageViews`/`MarkdownRenderer` + the tray/strip panels) and its tests. Earlier milestones (2.0.1 released on Marketplace; 2.1.0 unpublished — Marketplace blocked it on `findEnabledPlugin` internal API; 2.2.0 unblocked publication; 2.2.2 = full test pyramid; 3.2.1 = DeepSeek provider; **3.3.0 = full binary→host protocol surface mapped into the UI**: native MCP `elicitation` cards + correct `request_user_dialog` handling, predicted-next-prompt chip, live reasoning-token estimate, evolving hook-execution rows, memory-recall row, tool-use-summary/file-upload notices, plus the on-demand `./gradlew checkDrift` protocol drift detector). **3.0.0 nativizes the whole Agent SDK protocol surface** (all `system/*`+stream events, all host→binary control requests wired to GUI), with a redesigned composer, attachments + image drag&drop/paste, subagent strip, advanced launch options, plan mode, session rename/fork/delete, native hooks, and account/diagnostics dialogs — after a god-object decomposition and a final hardening pass. MVP + GUI complete and building clean. +Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, name **"Claude Code Native"**, version **5.0.0**, compatibility **251 → latest EAP/RC** (compiled against IC 2025.2; floor lowered from 252 in 4.3.1 — 251 is as far back as the API reaches with ZERO deprecations: `FileChooserDescriptorFactory.multiFiles()/singleDir()` in `FilePickerHelper` does not exist on 242/243, and its pre-251 equivalent is deprecated on current IDEs. Verified offline against locally-extracted IDEs via `-PlocalIdePath=[,…]`, which now takes a comma-separated list). **5.0.0 = the standards-compliance major.** The repository was put through the standards catalogue domain by domain, and the major reflects that the *code* changed, not just the docs. It did NOT stay purely that: it also ships the **plan-limits panel** (`get_usage`, a control request known since 4.0.1 and never sent — all rate-limit windows plus the extra-credit balance, as dashboard bars and composer dots, blue <65% / amber <85% / red above, announced once per threshold per window) and a run of user-facing fixes, the largest being a **tab-killing NPE this branch itself introduced**: `JcefChatPanel.pendingUntilReady` was declared BELOW the `init` block that uses it, and Kotlin runs property initializers and `init` blocks in declaration order — so it was null inside the constructor and NO chat could be opened or restored. `lastUsage`/`lastUsageAt` had the same defect and stayed silent (nullable/primitive read as null/0), which is why `InitOrderContractTest` now scans the sources: the compiler only flags a *direct* reference in an initializer, not one made through a function called from `init`. Also from that pass: a **boot screen** (the binary is launched BEFORE the tab is built, since `start()` only dispatches; FOUR states — running / starting / **binaryMissing** (the install-or-path onboarding card) / neither, that last being a launch that failed for another reason and MUST clear the screen); context and cost polled on ready, tab-open and both turn edges instead of waiting out a `javax.swing.Timer` whose initial delay equals its 60s interval (and the timer now retires at turn end — those numbers cannot move while idle); the CLI's `` wrapper stripped in `ProtocolParser.unwrapToolError` (verified in 2.1.222, which carries the same text unwrapped in a sibling field — rendering it verbatim put raw markup in a native GUI); failed tool cards auto-open once and wrap their error text (collapsed, the whole message was "the header is red"); `ToolSearch` + `AskUserQuestion`/`Mcp`/`FileRead`/`FileEdit`/`FileWrite` added to `SensitiveGuard.AGENT_TOOLS` — **that list is only ever appended to**, it is a trust allowlist and not an inventory, and `ToolSearch` was the load-bearing gap (it loads every deferred tool's schema, so on a session that defers them the call unlocking all the others was landing in the untrusted branch); and markdown links whose href is a path now open (`LinkResolver.isFilePathHref`, with a two-or-more-character scheme test so a Windows drive stays a path) through the same `isOpenable` gate as `jb://`. (1) **Dependency scope corrected** — `@anthropic-ai/claude-agent-sdk` sat in `dependencies` while it is protocol reference only, producing 7 permanent npm-audit findings (3 high) against code no user receives; moved to `devDependencies` (`npm audit --omit=dev` → 0), `checkDrift` verified green across the move (it reads the SDK from `node_modules` and runs `npm update`; only `--omit=dev` would break it) and re-baselined to `claude` 2.1.222 / SDK 0.3.222. `package.json` also declared `"license": "ISC"` on a GPL-3.0-only repo and was missing `"private": true` — i.e. publishable to npm under the wrong licence. (2) **`LoginCoordinator` extracted** from `ClaudeSession` (1965 → 1826 lines) — the OAuth subsystem and its three state fields; mechanical, no behaviour change, 677 tests green across it. The other two extractions the plan proposed (`SessionRestorer`, `RewindCoordinator`) were **deliberately not done**: `restore` is 23 lines that write six pieces of session state, and rewind is one of six identically-shaped `controlClient.query` delegates — extracting either buys indirection, not cohesion. (3) **Accessibility** — WCAG 4.1.3 live region (`#a11y-status`, declared in the static shell so the first write is announced) + `CC.announce`, and a `:focus-visible` baseline with a `forced-colors` fallback, pinned by 10 frontend tests; the EU Accessibility Act has applied since 28-jun-2025. (4) **Attribution ships inside the artifact** (`THIRD-PARTY-NOTICES.md`, `LICENSE`, `LICENSES/*` under `META-INF/`) — a permissive licence's notice obligation binds on *redistribution*, and the plugin redistributes marked/DOMPurify/highlight.js. (5) **Governance**: commitlint + a versioned `.githooks/commit-msg` that degrades to advisory if the toolchain fails (so it never becomes a reason to reach for `--no-verify`), `.gitattributes`, and three ADRs — [0001](docs/adr/0001-release-process.md) release process (GitFlow and GPG-on-YubiKey as *recorded deviations*, tag immutability as a **correction**: `v4.3.2` and `v4.4.1` were each force-re-cut three times, which is exactly what a signature is supposed to prevent; plus the generated-CHANGELOG deferral with a one-command exit test), [0002](docs/adr/0002-threat-model.md) threat model (trust model + STRIDE over binary/MCP/model-content; prompt injection is **assumed to succeed**, not detected), [0003](docs/adr/0003-i18n-deferred.md) i18n deferred with its triggers. **4.4.1 = `/login` terminal launch fixed (REAL regression, silent).** Every platform API `TerminalLauncher` reflected on was missing at runtime: the Reworked path looked up `com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager`, which is NOT in the shipped IDE at all (scanned every jar of IU-262.8665.337), and the Classic path used `TerminalToolWindowManager.createShellWidget(…)`/`.createLocalShellWidget(…)`, present on 251/252 but REMOVED by 262. Each lookup returns false rather than throwing → totally silent, nothing in idea.log, `/login` always landed on the "run it yourself" notice. Fix: `createNewSession(workingDirectory, tabName, shellCommand, requestFocus, deferSessionStartUntilUiShown)`, verified by hand on 251+252+262, with the login passed as **argv** (`TerminalLauncher.loginArgv`) not a shell string — killing the quoting hazard (Windows `&` prefix, spaces) and the send-into-a-shell race at once. **Why CI missed it:** the plugin compiles/tests against IC-2025.2, where the removed factories still exist — the break only manifests at 262+, so `TerminalApiContractTest` pins the replacement against the build classpath and `verifyPlugin`'s range run is the complementary half. Also wired `ClaudeLoginFlow` (pty4j) in as a REAL fallback — it was unreachable code, since `startLogin()` called the terminal unconditionally — so order is now terminal → native PTY → manual notice; and fixed a latent bug there: pty4j REPLACES the child env wholesale (unlike `ClaudeProcess`, which inherits via `withParentEnvironmentType(CONSOLE)`), so `System.getenv()` must be merged in or the spawned binary loses `PATH`/`HOME`. **4.4.0 = per-rule security toggles + `AGENT_TOOLS` allowlist fix.** Each `SensitiveGuard` rule (CREDENTIAL, DANGEROUS_COMMAND, and FOREIGN split into its three sub-rules via `ForeignReason`) is independently switchable via five `Policy.enforce*` fields ← `ClaudeSettings.securityBlock*` ← Settings ▸ Claude Code ▸ Security; all default true = the original hard lock. Detection (`classify()`) runs UNCONDITIONALLY — a toggle only downgrades the OUTCOME `DENY`→`ASK` (for every caller, MCP/Skills included), never to ALLOW, so a disabled rule is still a card every time. `reason()` always names the Settings path. `AGENT_TOOLS` had gone stale as the CLI grew its own orchestration surface (`Task*`, `Cron*`, worktrees, `Agent`, `SendMessage`, MCP-resource tools…), so those FIRST-PARTY calls fell into the untrusted branch and were hard-DENIED like a blocked MCP server; rebuilt from the vendored SDK's `ToolInputSchemas`, with `Skill`/`mcp__*` still deliberately excluded. NB FOREIGN denies regardless of caller trust by design, so the allowlist fix only changes CREDENTIAL/DANGEROUS_COMMAND outcomes. **4.3.3 = model-picker autodetect + Opus pinned as default.** The picker was ALREADY autodetected from the `initialize` catalog, but it labelled entries with the binary's `displayName`, which omits the version ("Opus (1M context)", "Sonnet") — so Opus 4.8 vs Opus 5 was indistinguishable. The version lives in `description` ("Opus 5 with 1M context · …"), so `JcefState.modelDisplayLabel` now prefers that description head (→ `displayName` → `deriveModelLabel(id)`), and BOTH selectors (composer pill/menu + the Settings combo renderer) share it so they can't disagree. The binary lists a floating `default` alias AND the concrete `opus[1m]` it resolves to — the same model twice, the alias with no version — so `default` is filtered out of both lists (`ClaudeSession.RECOMMENDED_ALIAS`) and `DEFAULT_MODEL` is now the CONCRETE `opus[1m]` (was `"default"`), pinning Opus even if the binary re-points its recommendation. `ClaudeSession.preferredDefault(models)` is the graceful fallback (pin → binary's recommended alias → first listed), so we never select a model the binary doesn't offer; a legacy persisted `"default"` migrates on display (`reset()`) and via `changeModel`. Also killed a hardcoded `"Default · Opus 4.8"` pill literal that had gone stale the moment the recommended tier became Opus 5 — no version is baked in anywhere now. Re-baselined to `claude` 2.1.220 / SDK 0.3.220 (`checkDrift` green, protocol surface unchanged). **4.3.2 (re-cut) = command code block + syntax highlighting + two SensitiveGuard false-triggers.** The executed command renders as its own copyable code block in `.tool-cmd` — a SIBLING of `.tool-out`, so it's visible WITHOUT expanding the card (only the output stays behind the collapse toggle) — the header shows just the tool name (no raw-command churro), and the card gets a `cmd-tool` left accent. Detection is by input SHAPE, not tool name (`SensitiveGuard.commandText`/`isCommandCall` → `TranscriptEntry.commandText` → `JcefBridge` `command` field), so Bash, PowerShell and any MCP exec tool are covered by one rule that can't drift from the security rules it shares. Diffs and Read/Write/Edit output are syntax-highlighted from the file extension (`CC.languageForPath` → ~35 langs in the vendored hljs bundle; hljs autodetection as fallback), layered under the existing add/remove diff colouring. The two security fixes were REAL false-triggers found live: `isUnc()` flagged ANY `//`-prefixed string — including an ordinary `// comment` line inside an `Edit`'s `old_string` (`pathCandidates` walks every string leaf) — as a UNC share, i.e. FOREIGN, which hard-DENIES regardless of caller trust, so editing a commented line could be silently refused with no override; fixed by requiring the post-`//` host segment to be non-blank and whitespace-free. And `substituteAssignments` passed a shell-assigned value straight to `String.replace(Regex, String)`, which treats it as a REPLACEMENT TEMPLATE — a value containing `$`/`${…}` threw an uncaught `IllegalArgumentException: Illegal group reference` (confirmed via idea.log stack trace), crashing `verdict()` and leaving that `can_use_tool` unanswered; fixed with `Matcher.quoteReplacement`. **4.3.2 = WSL `/mnt/c` fix.** WSL2 surfaces the Windows `C:` drive over 9p (in `RemoteMounts.REMOTE_FS_TYPES`), so `detect()` put `/mnt/c` in `remoteRoots` and the startup gate (`RemoteMounts.isRemote`) refused to launch on a normal `C:\` project (and the same `remoteRoots` fed `SensitiveGuard`'s foreign rule). Fixed two layers: `detect()` drops all `/mnt/*` from `remoteRoots` under WSL (governed by the dedicated `/mnt/c` rule), and `isRemote` exempts `/mnt/c` before the fstype checks. **4.3.1 = deterministic sensitive-data lock (`permission/SensitiveGuard`, `session/RemoteMounts`) + jump-to-code + the chat-focus fix + live VFS refresh.** The sensitive-data lock intercepts every `can_use_tool` in `PermissionBroker.handle` before any auto-approval (so it holds in bypass/acceptEdits): credential/key globs (structural, cross-OS incl. WSL) + dangerous-command regexes (after path canonicalisation + shell de-obfuscation) + foreign territory (other user's home, network/UNC mount, non-`/mnt/c` WSL drive); agent tools ASK, MCP/Skills DENY, foreign DENY-for-all, no opt-out; project root exempt; won't start on a remote-mounted project. Validated live (native Read of `~/.claude/.credentials.json` → card in bypass; MCP → denied). Jump-to-code links in the transcript: a file tool card names its file PROJECT-RELATIVE and links it (`ClaudeSession.toolFilePath` → `TranscriptEntry.filePath` → `renderToolLabel`), and paths/dirs/symbols in model text are linked only after the host CONFIRMS them (`ui/LinkResolver.kt`: file index → Go-to-Symbol EP → bounded on-disk scan for excluded dirs like `build/`; unambiguous matches only, so no dead/misleading links). Security: `LinkResolver.isOpenable` (project OR $HOME, canonical, symlink-safe) gates opening — the WRITE gate (`DiffPresenter.isWithinRoot` in `PermissionBroker`/`FileRollback`) stays project-only. **The focus bug** that made a new tab unusable was NOT the JCEF bridge (three wrong hypotheses before the log settled it): the tab never declared `Content.preferredFocusedComponent` (and it must point at `cefBrowser.uiComponent` — `JBCefBrowser.getComponent()` is a non-focusable wrapper), and a raw `requestFocusInWindow()` is REFUSED while `IdeFocusManager` settles focus (measured: denied 34×). Fix = `setSelectedContent(content, requestFocus = true)` (the ContentManager transfers focus as part of the selection, the same path a manual tab switch takes) + telling CEF it has focus in `JcefHost.markWebReady()` — i.e. once the page EXISTS, since a freshly loaded page starts with its focus flag cleared and paints no caret. **VFS refresh is now per-write, not per-turn** (`ClaudeSession` ToolResult → `DiffLifecycleManager.refreshTouched()` for the exact paths + `refreshProjectTree()` when `mayHaveWrittenUnknownFiles(tool)` — Bash or a mutating MCP tool); `refreshTouched` also refreshes the PARENT dir, because refreshing a file the VFS has never heard of is a no-op and a newly CREATED file stayed invisible. NB `PluginId.getId(…)` is banned: `PluginId` is a Kotlin class since 2025.2, so it binds to `PluginId.Companion` and dies with `NoSuchFieldError` on any IDE below 252 — use `util/InstalledPlugins.kt` (id from the descriptor). **4.2.0 was a protocol-upgrade + dashboard release** — re-baselined to `claude` 2.1.204 / SDK 0.3.204: models `system/background_tasks_changed` (a **level** signal — the binary re-sends the FULL live background-task set on every membership change; tracked in `TaskTracker.backgroundTasks` with REPLACE semantics, kept **deliberately uncorrelated** with the edge-derived `subagentTasks` because the SDK leaves their relative ordering unspecified, and reset per-process in `clear()`) and surfaces it as a **"Background tasks"** dashboard card with Stop (`JcefSessionData.backgroundTasksJson` + `app-session.js buildBackgroundTasksCard`) — unlike the edge-derived Subagents list it can never wedge a stale "running" indicator; also models `system/control_request_progress` (progress for a host-originated control request, currently `side_question`/`/btw`: an `api_retry` status carries the same counters as `system/api_retry` and is surfaced the same way, `started` goes to debug). Triages the thin-client host→binary control requests the plugin knowingly never sends — `list_models` (the model catalog comes from the `initialize` reply), `get_plan`, `get_workspace_diff` — into `ProtocolSurface.KNOWN_SUBTYPES`. `./gradlew checkDrift` green at the new baseline. **4.1.0 adds editable diff review for edits:** when Claude asks to Edit/Write/MultiEdit, the plugin auto-opens an **editable** diff in the IDE editor (Current | Proposed, proposed side via `DiffContentFactory.createEditable`) on the permission request — not just in acceptEdits/bypass; the user can **tweak the proposed content** before accepting, **Accept writes their edited version** (`HunkSelection.encodeInput` re-encodes the tool input; fail-safe to the original proposal when unchanged/read-only), the captured snapshot is repointed at the effective input so the transcript inline diff + "View diff" show the **real** written change, and the diff closes on accept/reject/stop/interrupt (`DiffPresenter.openReviewDiff` + `DiffLifecycleManager` review-diff registry + `EditSnapshotStore.updateInput`). **4.0.5** replaced the permission card's per-hunk checkboxes with a **read-only colour diff** (per-line partial accept produced incoherent/broken edits; edits are now atomic — accept/reject the whole change). **4.0.4 (branch `bugfix/various-fixes`) is a broad bug-fix + UX pass:** the **interrupt** now actually stops the turn (correlated control request clears `turnActive`; transient "Interrupting…" on the Stop button via a `session.interrupting` flag; queue + pending permission cards flushed) instead of looping the "Interrupting…" notice forever; **first-open dead chat** is self-healed (the web app retries `ready` until `window.__ccSend` exists, and `JcefHost` reloads via `loadHTML` if the page doesn't come alive — kills the "reopen the tab" workaround); **user prompts render verbatim** (`buildUser()` is `kind:'text'`, never Markdown); the code-block **Copy** button works (a delegated `document` handler replaced the listener lost on `innerHTML` serialization); duplicate/out-of-order **"Thought process"** fixed in `TranscriptReconciler` (a `settledThinking` pointer finalize-replaces the streamed entry); **menu flicker/de-selection during streaming** fixed (incremental `renderState`, open menu rebuilt only when its selection changed; `JcefChatPanel.onAdded` no longer forces a full structural re-serialization for tail appends — was O(N²)); single ✓ in prompt menus; Esc on the find bar no longer also interrupts; **"Always allow"** resolves the exact card (carries the `requestId`, not first-by-tool-name) and a **zero-hunk accept is a deny**; permission re-push reconciles by `card.id` (no wiped elicitation/question/hunk input); the session **dashboard** lays out (`.dash-inner` grid, hides `#conversation` while open) without covering the composer; **clipboard paste runs off-EDT** with a deadline (no IDE freeze on a hung Wayland clipboard); the **find bar** scrolls to the active hit + Enter/Shift+Enter navigation (`i / n`); **adaptive thinking is on by default** (`ClaudeSettings.thinkingTokens = THINKING_ON`); faster Vibe Mode rainbow; **responsive** composer (pills wrap) / find / chips + truncated tab titles (full title in tooltip). Latent fixes: a `starting` guard + generation re-checks prevent a double `claude` spawn / mid-launch orphan, `dispose()` bumps the generation (no spurious "exited unexpectedly"), a malformed `can_use_tool` can't throw+hang the turn (replies error), and `ClaudeToolWindowFactory` resolves its tool window per-project (no shared-state cross-project bug). **Protocol re-baselined to `claude` 2.1.193 / SDK 0.3.193** — models `system/informational`·`model_refusal_no_fallback`·`worker_shutting_down`; `./gradlew checkDrift` green. **4.0.3 fixed composer clipboard paste on native-Wayland IDEs** — under `sun.awt.wl.WLToolkit` the embedded CEF browser's web clipboard is isolated from the system clipboard, so the composer's `paste` event never reached the host. `JcefState.metaJson` now emits a `hostClipboard` flag (true under the Wayland toolkit) and `app-composer.js` routes `Ctrl+V` straight to the host, which reads the real clipboard via `wl-paste`/`xclip` (the path the Attach→Image button already used). 4.0.2 had added that host-side `wl-paste`/`xclip` *read* fallback (`EditorContextProvider.clipboardText`/`clipboardHasText`, guarded by the pure `preferredTextType`) but it was never reached — the bug was the trigger, not the read (AWT/`CopyPasteManager` *reads* are broken on native Wayland; *writes* work). **4.0.1 is a protocol-upgrade release** — re-baselined to `claude` 2.1.170 / SDK 0.3.170: models the new `system/model_refusal_fallback` message (primary model refuses → turn retried on a fallback model; surfaced as a transcript notice) and triages the new `get_usage`/`register_repo_root`/`reload_skills` host→binary control requests into `ProtocolSurface.KNOWN_SUBTYPES`, so `./gradlew checkDrift` is green again. **4.0.0 rebuilds the entire chat UI on JCEF** (embedded Chromium web view — modern streaming transcript, web composer, native permission/question/elicitation cards, and a session dashboard; see "## JCEF UI (4.0.0)" above), and **deletes the old Swing chat UI** (`ChatPanel`/`TranscriptView`/`ChatMessageViews`/`MarkdownRenderer` + the tray/strip panels) and its tests. Earlier milestones (2.0.1 released on Marketplace; 2.1.0 unpublished — Marketplace blocked it on `findEnabledPlugin` internal API; 2.2.0 unblocked publication; 2.2.2 = full test pyramid; 3.2.1 = DeepSeek provider; **3.3.0 = full binary→host protocol surface mapped into the UI**: native MCP `elicitation` cards + correct `request_user_dialog` handling, predicted-next-prompt chip, live reasoning-token estimate, evolving hook-execution rows, memory-recall row, tool-use-summary/file-upload notices, plus the on-demand `./gradlew checkDrift` protocol drift detector). **3.0.0 nativizes the whole Agent SDK protocol surface** (all `system/*`+stream events, all host→binary control requests wired to GUI), with a redesigned composer, attachments + image drag&drop/paste, subagent strip, advanced launch options, plan mode, session rename/fork/delete, native hooks, and account/diagnostics dialogs — after a god-object decomposition and a final hardening pass. MVP + GUI complete and building clean. **4.0.0 post-rewrite UI/UX hardening (frontend-only — the Kotlin backend was untouched, validating the binary-direct architecture):** subagent activity nests inside its Agent/Task card with per-card collapse (was a CSS descendant-selector bug); **native rewind as the default rollback** — "Restore" asks Claude Code to `rewind_files` to that turn (client-tagged user-message `uuid` + `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING`, setting default-on), with a confirmed IDE-side per-file revert fallback (`ClaudeSession.requestRewindFiles`/`userMessageIdFor`); **clipboard paste on Wayland** read host-side (image via `wl-paste`/`xclip` resolved across common bin dirs, plus `text/uri-list` for copied image files; **text via AWT with a `wl-paste`/`xclip` fallback added in 4.0.2** for the native Wayland toolkit); tool-card states (loading/running fade sky-blue↔amber, done green, **error red** via `ToolState.ERROR`), colourised inline edit diffs, flat single-row composer control bar with the ported icon set, Ctrl+O reasoning toggle (collapsed by default), auto-follow toggle, 🌈 Vibe Mode (Nyan Cat + rainbow), diffs open without stealing keyboard focus, request cards capped at 50% height (scrollable body, actions always visible) with a Cancel on question cards, `/login` runs in the IDE terminal (browser auto-capture) and appears in the palette, "Explain with Claude" carries the Claude icon, and the ⚙ menu reuses the formatted JCEF dashboard. Fixes: a non-compiling tree (`object a ChatTheme` + a nested-comment KDoc), and session-cost + JetBrains-MCP reading the binary's `mcpServers` (camelCase) reply. @@ -74,7 +74,7 @@ Package `dev.lain.claudejb`, plugin id `dev.lain.claude-code-for-jetbrains`, nam **Test pyramid (694 tests in the default `test` task + 84 frontend, 0 failures, 2 Windows-only skips; the on-demand `checkDrift` task adds the `driftLive` check):** (A) **unit** (pure JVM) — protocol parse/build, diff reconstruction, edit-snapshot capture, permission tool_use_id plumbing + the exhaustive `PermissionBroker` matrix, hunk reconstruction/encode, markdown rendering + edge cases, `DiffPresenter.isWithinRoot` (incl. symlink escapes), `ClaudeBinaryLocator`, `McpConfigBuilder`, `parseAskQuestions`, session open-tab id (de)serialization, `SessionStore` path-traversal guard + cwd encoding, `SessionTitleReader`/`SessionTranscriptReader` JSONL parsing, settings enums, transcript hierarchy, rate-limit math and env parsing; (B) **headless component** (`src/test/.../headless/`, `BasePlatformTestCase` in-process) — `OpenedDiffsService`, `ChatSessionManager`, `SessionHistory`/`ClaudeSettings` services, `ClaudeSettingsConfigurable`, and real token accounting via the `@TestOnly` `ClaudeSession.handleEventForTest` seam; (C) **integration** (`src/test/.../integration/`) — a real `ClaudeSession` driven against the deterministic `bin/fake-claude` Python stand-in with JSONL fixtures (init, streaming, thinking, token fold, rate-limit, tool permission, resume, interrupt, Write-unsafe regression); (D) **UI end-to-end** (`src/uiTest/`, RemoteRobot, gated by `-PuiTest.enabled=true`, nightly); (E) **frontend** (`src/test/frontend/`, **vitest + jsdom**, run with `npm test` — devDependencies only, nothing ships in the plugin) — loads the real inlined `resources/jcef/*.js` (vendored `marked`/`DOMPurify`/`highlight` first, then app-core, then the module) into a jsdom shell (`helpers/load.js`) and drives the public `window.cc.*`/`CC` surface: a **JS↔CSS class contract** (the check that would have caught the missing `.mcp-actions` rule), user-prompt verbatim render, code-block Copy decoration + the delegated handler, inline diff colouring, the MCP card / switch / `wide` cards, permission read-only diff + reconcile-by-id, the composer send/stop/interrupting button, and (5.0.0) the **accessibility contract** in `accessibility.test.js` — the live region declared in the *static* shell rather than created on first use, `CC.announce` dedup, the permission announcement, the `:focus-visible` replacement for every suppressed outline, `forced-colors`, and `lang` on the document. NB `helpers/load.js` now extracts the shell DOM from the real `shell.html` instead of hand-copying it: the hand-copy had already drifted (it lacked `#a11y-status`), which is the worst failure mode a harness has — it doesn't fail, it quietly tests something else. Wired into CI as the `Frontend tests` job (Node image), a required check on both protected branches. NB: on this machine node-24 needs `OPENSSL_CONF=/dev/null` (a local env quirk, not needed on the clean CI image). Coverage via `kotlinx-kover` (`./gradlew koverHtmlReport`). NB: headless+integration run inside the plugin's own `test` task (the IntelliJ Platform Gradle plugin only instruments that task with the platform runtime); a hand-rolled Test task would miss `Project` on its classpath. -**Maintenance workflow (the plugin has real Marketplace users):** the CI/CD is **GitHub Actions** (5.0.0). The long-standing "GitHub Actions is capped by billing" claim in this file and in `.gitlab-ci.yml` was simply **FALSE** — the repo is public, and Actions on standard hosted runners is free and unmetered for public repos (verified: `gh api repos/…/actions/permissions` → `enabled: true, allowed_actions: all`). The workflows had just been deleted at some point and the billing story lived on in a comment. `.gitlab-ci.yml` is now REMOVED (not kept alongside: two pipelines that can each publish is one publisher too many). Four workflows, every action pinned by full commit SHA with Dependabot proposing bumps: **`ci.yml`** (push to develop/main/`feature|bugfix|hotfix/**` + PRs → JVM tests, frontend tests, `npm audit --omit=dev` as the blocking scope, `verifyPlugin`, `buildPlugin` + two artifact assertions: zero `node_modules` entries and `META-INF/{LICENSE,THIRD-PARTY-NOTICES.md}` present, i.e. the claims SECURITY.md makes are enforced rather than trusted); **`codeql.yml`** (`java-kotlin` manual-build + `javascript-typescript`, `security-extended`, weekly); **`release.yml`** (tag `vX.Y.Z` only → `guard` asserts the tagged commit is REACHABLE FROM `main` and that the tag matches `build.gradle.kts`'s version, BEFORE any secret is in scope → full gate on the tagged tree → build once + SLSA attestation → `publish` gated on the **`marketplace` GitHub Environment** with a required reviewer, credentials scoped there and nowhere else → GitHub Release). The lineage guard is the load-bearing one: without it anyone who can push a tag can publish from any code, and the PR review the approval assumes becomes optional; **`drift.yml`** (weekly `checkDrift` against a freshly installed CLI + latest SDK, **files an issue**, never commits — reconciling drift is a judgement call). Branch protection is VERSIONED in `.github/rulesets/{main,develop}.json` and applied by `scripts/apply-rulesets.sh` (idempotent, updates by name); no bypass actors, not even admins — the old documented admin bypass existed for a structural blocker (capped Actions) that never existed. NB a ruleset references a check by the job's DISPLAY NAME: renaming a job doesn't fail the gate, it silently stops applying. Policy docs in `docs/` (`RELEASE_PROCEDURE`, `RELEASE_CHECKLIST`, `BINARY_COMPAT`, `BRANCHING`, `FAQ`, `TROUBLESHOOTING`, `TELEMETRY`) plus `SECURITY.md`, `CONTRIBUTING.md`, `CODEOWNERS`, issue/PR templates, `dependabot.yml`, and `scripts/probe-binary.sh`. **Implemented features:** protocol+transport, multi-chat with queue, permissions+native diff, AskUserQuestion, markdown tables, auto-diff on acceptEdits/bypass, multi-line commands, Ctrl+O reasoning, quota bar + spinner/tokens, menus that close on selection, `/btw`, UI rethemed to IDE theme, **Windows support**, **persistent settings** (model/mode/effort/thinking/tools/env via `ClaudeSettings` + settings UI), **plugin is the source of truth for `permissionMode`**. `claude` 2.1.220 at `~/.local/bin/claude`; SDK reference (protocol-only) `node_modules/@anthropic-ai/claude-agent-sdk@0.3.220`. +**Maintenance workflow (the plugin has real Marketplace users):** the CI/CD is **GitHub Actions** (5.0.0). The long-standing "GitHub Actions is capped by billing" claim in this file and in `.gitlab-ci.yml` was simply **FALSE** — the repo is public, and Actions on standard hosted runners is free and unmetered for public repos (verified: `gh api repos/…/actions/permissions` → `enabled: true, allowed_actions: all`). The workflows had just been deleted at some point and the billing story lived on in a comment. `.gitlab-ci.yml` is now REMOVED (not kept alongside: two pipelines that can each publish is one publisher too many). Four workflows, every action pinned by full commit SHA with Dependabot proposing bumps: **`ci.yml`** (push to develop/main/`feature|bugfix|hotfix/**` + PRs → JVM tests, frontend tests, `npm audit --omit=dev` as the blocking scope, `verifyPlugin`, `buildPlugin` + two artifact assertions: zero `node_modules` entries and `META-INF/{LICENSE,THIRD-PARTY-NOTICES.md}` present, i.e. the claims SECURITY.md makes are enforced rather than trusted); **`codeql.yml`** (`java-kotlin` manual-build + `javascript-typescript`, `security-extended`, weekly); **`release.yml`** (tag `vX.Y.Z` only → `guard` asserts the tagged commit is REACHABLE FROM `main` and that the tag matches `build.gradle.kts`'s version, BEFORE any secret is in scope → full gate on the tagged tree → build once + SLSA attestation → `publish` gated on the **`marketplace` GitHub Environment** with a required reviewer, credentials scoped there and nowhere else → GitHub Release). The lineage guard is the load-bearing one: without it anyone who can push a tag can publish from any code, and the PR review the approval assumes becomes optional; **`drift.yml`** (weekly `checkDrift` against a freshly installed CLI + latest SDK, **files an issue**, never commits — reconciling drift is a judgement call). Branch protection is VERSIONED in `.github/rulesets/{main,develop}.json` and applied by `scripts/apply-rulesets.sh` (idempotent, updates by name); no bypass actors, not even admins — the old documented admin bypass existed for a structural blocker (capped Actions) that never existed. NB a ruleset references a check by the job's DISPLAY NAME: renaming a job doesn't fail the gate, it silently stops applying. Policy docs in `docs/` (`RELEASE_PROCEDURE`, `RELEASE_CHECKLIST`, `BINARY_COMPAT`, `BRANCHING`, `FAQ`, `TROUBLESHOOTING`, `TELEMETRY`) plus `SECURITY.md`, `CONTRIBUTING.md`, `CODEOWNERS`, issue/PR templates, `dependabot.yml`, and `scripts/probe-binary.sh`. **Implemented features:** protocol+transport, multi-chat with queue, permissions+native diff, AskUserQuestion, markdown tables, auto-diff on acceptEdits/bypass, multi-line commands, Ctrl+O reasoning, quota bar + spinner/tokens, menus that close on selection, `/btw`, UI rethemed to IDE theme, **Windows support**, **persistent settings** (model/mode/effort/thinking/tools/env via `ClaudeSettings` + settings UI), **plugin is the source of truth for `permissionMode`**. `claude` 2.1.223 (a system-wide install at `/usr/bin/claude` on this machine — `checkDrift` defaults to `~/.local/bin/claude`, so pass `-PclaudeBinary=/usr/bin/claude`); SDK reference (protocol-only) `node_modules/@anthropic-ai/claude-agent-sdk@0.3.223`. v2.0.0 hardening: EDT-freeze fix on start (env resolution + spawn off-EDT, cached), pendingControl drained on stop/crash, 30s control-request watchdog, start-failure surfaced, auto-writes confined to project root, trust-on-open gate for source script / custom stdio MCP, safe source-script quoting, plaintext-env warning in Settings. diff --git a/docs/ci-signing-key.asc b/docs/ci-signing-key.asc index e2a2d53c..2e300539 100644 --- a/docs/ci-signing-key.asc +++ b/docs/ci-signing-key.asc @@ -1,14 +1,14 @@ -----BEGIN PGP PUBLIC KEY BLOCK----- -mDMEanNlBRYJKwYBBAHaRw8BAQdAyRg3jhh+IuekRayUcDmgQgTHJNjbtRacv5Fj -STWNNdK0SUNsYXVkZSBDb2RlIE5hdGl2ZSBDSSAocmVsZWFzZSBhcnRpZmFjdHMg -b25seSDigJQgTk9UIHRoZSBtYWludGFpbmVyIGtleSmImQQTFgoAQRYhBIHdxQ/r -WupPJSbioIvP0huNQLU4BQJqc2UFAhsDBQkB4TOABQsJCAcCAiICBhUKCQgLAgQW -AgMBAh4HAheAAAoJEIvP0huNQLU4p9EA/2zqIcTJZZrHyhRrF6voaZo/D/eH37PO -UxEuIc/Kwi3lAP9qQgz0U3wSL9UKknGH9sTSvl8wcuiDlhSXThRBHljrAIiVBBAT -CQAdFiEEbNMGdWEyxv3e6Ip0zQwS2DwEQ1oFAmpzZQcACgkQzQwS2DwEQ1qZVwGA -ksBT/+Lrn0CXd5kDWZHiOvLhXUDKhthi8P/Tfdudk+JF3AZmiZeQwXuI2hYQ+As/ -AX9uiO4q5kjN06xLxQShpyLb/+uLO3NHCivxSiSgBlNgsTLLcUDSuCCYTa73zklN -Rk8= -=HS6V +mDMEanQWphYJKwYBBAHaRw8BAQdA6TIfb0hlLWMPUFPN16vEP50s1+akZV1g8Atv +2R3i+qe0ZkNsYXVkZSBDb2RlIE5hdGl2ZSBDSSAocmVsZWFzZSBhcnRpZmFjdHMg +b25seSDigJQgTk9UIHRoZSBtYWludGFpbmVyIGtleSkgPGxhaW4uYWdlbnQ2MDRA +cGFzc21haWwuY29tPoiZBBMWCgBBFiEEtdQO2CTk4PX0gJosPUJ9JHMSuuEFAmp0 +FqYCGwMFCQHhM4AFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQPUJ9JHMS +uuEexwEA+RC8tUJQff3hBbMz4eG6Ii/z72omr22GEWK+5r18srgBANdYduOQakpx +iGvGtuH1UhGpx47m/G8ohyMUCRXI9fgNiJUEEBMJAB0WIQRs0wZ1YTLG/d7oinTN +DBLYPARDWgUCanQWqAAKCRDNDBLYPARDWokFAYDtpKeEpAwlFO018Lrh5oULk/sw +ZRV4tFvNpusIT5ZCIQdCnj0lD4yEYDdviZDZ98EBf1io9kZexAJOuftZUhMT0lbq +k2NxLs4i8xqI9LoHRe1/VL3GJjkD3paqYS0jeKQ8qA== +=S9+K -----END PGP PUBLIC KEY BLOCK----- diff --git a/package-lock.json b/package-lock.json index 7ab4a066..5b16bdeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "license": "GPL-3.0-only", "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.222", + "@anthropic-ai/claude-agent-sdk": "^0.3.223", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", @@ -22,23 +22,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.222.tgz", - "integrity": "sha512-muAyjIzXJjIpSrj91vSmnU76/z7rNlV8+lSuq48h4eUPSYVBSgOasgkfQAMUrIZJCfI15XF9/EYoRrx/eKD7Og==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.223.tgz", + "integrity": "sha512-r2BpOfxaEjPj0xpRQBukrBWG7n2QERVk10hstc+AXmm4JBii1OqH50sfewU8a8E7uhoJazA3WnZoZoaTFnV/2A==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.222", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.222", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.222", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.222", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.222", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.222", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.222", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.222" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.223", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.223", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.223", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.223", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.223", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.223", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.223", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.223" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -47,9 +47,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.222.tgz", - "integrity": "sha512-h3nCSwRbUsXDnRRYvMhzI1u1q5w6jClc9jTtOJBY6+mijjJEGk+Fa5Kuhw0Tb5ec+1iatiTyCIT46HrV0Skqcg==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.223.tgz", + "integrity": "sha512-y9PcAkK7JHfzBC1yyLIhJwlF/x7XYLcFReiKkxm53mtBp+ASgpxNoBDvGqx7Q+IVB5xwlhnprcaXc2PxsLUKuA==", "cpu": [ "arm64" ], @@ -61,9 +61,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.222.tgz", - "integrity": "sha512-kVzR6qBZv1ddbb+uJfDDEVdW6/cl1dtc2nAIa8prk4IcuyK1RwQE/BOO8dO6iRl5PnHuv9BFKS9AO1UErOkT5Q==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.223.tgz", + "integrity": "sha512-JAkGQCat4FroNdG9XQiKQ+M+R/mggt3fFVQR5KcsbOrGFNynCdnVhi9CPxU8AXDhiIbk+Y3254qHhKTZbcZtGw==", "cpu": [ "x64" ], @@ -75,9 +75,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.222.tgz", - "integrity": "sha512-wn4XYkaWbAXc8PoznZqjCqbqB/Qet8dNdpZuRtbNSA1yZR0vYg2u28C5zV0QqVTXGgQVBYFUZabouyoERL6PsA==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.223.tgz", + "integrity": "sha512-mFPm66MIiFtb/XiHR39/ifzv0nlho1KNCjH+1cP6lJvVXm57kugxsKDndGXWEBI0Wh7DxKhTnjRxEKVhwwdYWw==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.222.tgz", - "integrity": "sha512-sVvPAUzRq+Am3+wNrFJ7URaWORu8t5hOu4vJnMhOeey66ZFFfzNwP0uz/7sTWjg+cRse/wG/hek34lZ0Gdu2+w==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.223.tgz", + "integrity": "sha512-HKpj+0quFtaH/fXQm56HipslOyhyTdB2vM4nvxmG3oube2KtB9FNJqqnTwSTeGbJc5dm4PjoAyN+oQV8Rh4RGw==", "cpu": [ "arm64" ], @@ -109,9 +109,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.222.tgz", - "integrity": "sha512-deZh3tVoYy9ememF5oirZNx1une7IG79JbKrB3Cynggt2gnq3TFXvPfkVGWgNsocK035wAW9HhGZui832OFSqA==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.223.tgz", + "integrity": "sha512-8x+BvnuNr8iTMqwBAf0KvklF/GRLXYiXMb+umo+N2bxOlE7wFYpFqVACeW44KsfRgdnbVnF9sJ00w1s01W/9eA==", "cpu": [ "x64" ], @@ -126,9 +126,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.222.tgz", - "integrity": "sha512-vM48p8GRM5N57+jPFdPj1ZS8Y770frJ1NTRecA06/GXtRANn7dOUVpvJbpu6fJBFF3BCacM1uVcBCMZh6wEMjw==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.223.tgz", + "integrity": "sha512-ukw6GAneAsCc/Lp24cA4yk2/vPgDjooZGB/TTvLsyfy8yto3b/WpfaktubQE/tNigF+mWo6+VytUOeVil8HwnQ==", "cpu": [ "x64" ], @@ -143,9 +143,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.222.tgz", - "integrity": "sha512-VuJjTLP2jQ1j8yn44/YODFR8GhI60Tvzok6AaXUYpKgeZTbBndDOpqwxvdWAraJSSyDreSSMztYyI6lP6nVypg==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.223.tgz", + "integrity": "sha512-Am8q5al0BBIbxOlfLSjaXQqCOjOg/4XvCiSt8mxvocGy/6W60fV/RFrEs86RlN6DATUGxTSNTqye6cn8MTlgOA==", "cpu": [ "arm64" ], @@ -157,9 +157,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.222", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.222.tgz", - "integrity": "sha512-mr4eZ+bmZA0hPl2wN031qy3TJXZJZIpdE4khvCc0AJ7ljVpJUEG654wz9NJoPxfPo4cRg41niHqUU5rN9BfQVw==", + "version": "0.3.223", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.223.tgz", + "integrity": "sha512-ZG53F8YtUTr97OGdnIbSqHuRHL1eja928M1xwbuAiM1cJ5I8VpFODVjHpM0ioKiurr9Tmjzns/nHcafc18VovA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 554bc985..8687aad9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "license": "GPL-3.0-only", "type": "commonjs", "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.222", + "@anthropic-ai/claude-agent-sdk": "^0.3.223", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", diff --git a/scripts/bootstrap-ci.sh b/scripts/bootstrap-ci.sh index 225695c8..55590748 100755 --- a/scripts/bootstrap-ci.sh +++ b/scripts/bootstrap-ci.sh @@ -72,37 +72,48 @@ echo "environment: $ENVIRONMENT" # --- 1. environment ------------------------------------------------------------------------------------ say "1/6 Deployment environment" -REVIEWER_ID=$(gh api user -q .id) -REVIEWER_LOGIN=$(gh api user -q .login) -info "required reviewer: $REVIEWER_LOGIN ($REVIEWER_ID)" - +# NO required reviewer, deliberately, and this reverses an earlier decision rather than overlooking one. +# +# The approval existed as the human gate on an irreversible publish. On a single-maintainer repository it +# was not buying that: this account is the ONLY collaborator, `main` is protected and accepts nothing but +# pull requests, and the merge of that pull request is already a deliberate human act. The approval added +# a second click by the same person, moments later, over the same decision. +# +# What is genuinely lost is named rather than glossed: an automated publish now follows a merge without +# anyone confirming which VERSION is about to go out. The remaining guards are the reviewed pull request +# into main, the lineage assertion in release.yml's `guard` job, and the fact that `guard` refuses to +# publish a version whose tag already exists. +# # The body is built with jq and piped in, rather than assembled from -f/-F flags. Two reasons, both # learned the hard way: gh's `-f` sends STRINGS (so `-f wait_timer=0` is rejected as `"0"` is not an -# integer) while `-F` guesses the type, and the bracket syntax for an array of objects -# (`reviewers[][type]=`) is ambiguous enough that it is not worth relying on. A JSON document has -# exactly one meaning. -# -# prevent_self_review=false is REQUIRED here, not an oversight. You push the tag, so you are the -# deployment creator; with self-review prevented you would be the one person unable to approve it, and -# nothing would ever publish. On a single-maintainer project that setting is a deadlock, not a control. -jq -n --argjson id "$REVIEWER_ID" '{ +# integer) while `-F` guesses the type, and the bracket syntax for an array of objects is ambiguous +# enough that it is not worth relying on. A JSON document has exactly one meaning. +jq -n '{ wait_timer: 0, prevent_self_review: false, - reviewers: [{ type: "User", id: $id }], + reviewers: [], deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } }' | gh api --method PUT "repos/$REPO/environments/$ENVIRONMENT" --input - >/dev/null -info "environment created/updated with a required reviewer" - -# Restrict it to release tags: a second lock, independent of the workflow's own lineage guard. The guard -# checks the tag descends from main; this checks the environment is only reachable from a version tag. -if ! gh api "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \ - -q '.branch_policies[].name' 2>/dev/null | grep -qx 'v\*\.\*\.\*'; then - gh api --method POST "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \ - -f 'name=v*.*.*' -f type=tag >/dev/null - info "restricted deployments to v*.*.* tags" -else - info "tag policy v*.*.* already present" -fi +info "environment created/updated — publish runs without a manual approval" + +# Which refs may deploy. BOTH are needed and they are not interchangeable: +# +# main the primary path. release.yml triggers on a push to main and derives the tag from +# build.gradle.kts, so at deployment time github.ref is refs/heads/main. Without this entry +# the job is rejected outright with "Branch 'main' is not allowed to deploy to marketplace", +# before it even reaches the workflow — which is exactly how the first attempt failed. +# v*.*.* the manual escape hatch: re-cutting a release by pushing an explicit tag. +for policy in "main:branch" "v*.*.*:tag"; do + name=${policy%:*}; type=${policy##*:} + if gh api "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \ + -q '.branch_policies[].name' 2>/dev/null | grep -qxF "$name"; then + info "deployment policy '$name' already present" + else + gh api --method POST "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \ + -f "name=$name" -f "type=$type" >/dev/null + info "allowed deployments from '$name' ($type)" + fi +done # --- 2. Marketplace token ------------------------------------------------------------------------------ say "2/6 JetBrains Marketplace token" @@ -235,6 +246,34 @@ else info "wrote docs/ci-signing-key.asc (fingerprint $ci_fpr)" warn "COMMIT docs/ci-signing-key.asc — without the public key nobody can verify a release." + + # --- register the CI key on the GitHub ACCOUNT -------------------------------------------------------- + # This step did not exist, and its absence is the whole reason v5.0.0 shipped with an unverified tag. + # + # Certifying the key with the YubiKey (above) makes it trustworthy to a human running `gpg --verify`. + # It does nothing for the "Verified" badge, which is a different mechanism entirely: GitHub marks a + # signature verified only when the tagger email, an email in a uid of a key REGISTERED ON THE ACCOUNT, + # and a verified account email all agree. Certification is not registration, and the two were conflated. + if gh gpg-key list >/dev/null 2>&1; then + if gh gpg-key add docs/ci-signing-key.asc >/dev/null 2>&1; then + info "registered the CI public key on the GitHub account" + else + info "GitHub already knows this key (or rejected it) — verifying below" + fi + # The check that matters. A key registered with NO email can never verify a tag, which is precisely + # the state the previous key was in: `emails=` came back empty and nothing said so. + short=${ci_fpr: -16} + if gh api user/gpg_keys -q ".[] | select(.key_id==\"$short\") | .emails[]?.email" 2>/dev/null | grep -q .; then + info "the registered key carries an email — tags signed with it can be verified" + else + warn "the registered key lists NO email address. Tags signed with it will show as UNVERIFIED." + warn "Regenerate it with gen-ci-signing-key.sh (which now sets Name-Email) and re-run this step." + fi + else + warn "gh lacks the GPG scope, so the key was NOT registered on your account." + warn "Without this the release tag will show as unverified. Run:" + warn " gh auth refresh -s write:gpg_key && gh gpg-key add docs/ci-signing-key.asc" + fi fi # --- 5. verify ----------------------------------------------------------------------------------------- @@ -260,8 +299,15 @@ else info "no repository-level secrets (correct)" fi -gh api "repos/$REPO/environments/$ENVIRONMENT" -q \ - '" reviewers: " + ([.protection_rules[]? | select(.type=="required_reviewers") | .reviewers[].reviewer.login] | join(", ")) + " | self-review prevented: " + (.prevent_self_review|tostring)' +reviewers=$(gh api "repos/$REPO/environments/$ENVIRONMENT" \ + -q '[.protection_rules[]? | select(.type=="required_reviewers") | .reviewers[].reviewer.login] | join(", ")') +if [ -z "$reviewers" ]; then + info "no required reviewer — a merge to main publishes without a second confirmation" +else + warn "required reviewer(s) present: $reviewers — publish will WAIT for approval" +fi +info "deployments allowed from: $(gh api "repos/$REPO/environments/$ENVIRONMENT/deployment-branch-policies" \ + -q '[.branch_policies[] | "\(.type):\(.name)"] | join(", ")')" # --- 6. branch protection ------------------------------------------------------------------------------ say "6/6 Branch protection" diff --git a/scripts/drift-baseline.properties b/scripts/drift-baseline.properties index 81945b04..063cf93f 100644 --- a/scripts/drift-baseline.properties +++ b/scripts/drift-baseline.properties @@ -5,5 +5,5 @@ # These are the last *reconciled* versions: the detector updates the live tools to latest (npm update + # claude --update), then reports any protocol surface the plugin doesn't model yet. After reconciling a # report (and `npm update` having bumped the vendored SDK), bump these to match. -sdk=0.3.222 -binary=2.1.222 +sdk=0.3.223 +binary=2.1.223 diff --git a/scripts/gen-ci-signing-key.sh b/scripts/gen-ci-signing-key.sh index 98c8ccb7..e5910acf 100755 --- a/scripts/gen-ci-signing-key.sh +++ b/scripts/gen-ci-signing-key.sh @@ -39,6 +39,39 @@ fi NAME="Claude Code Native CI (release artifacts only — NOT the maintainer key)" EXPIRY="1y" +# The key MUST carry an email, and it must be the maintainer's verified GitHub address. +# +# The first version of this script set only Name-Real, so the key was generated with no email in its uid +# at all. Everything still worked — artifacts were signed, `gpg --verify` passed — except the one thing +# that is visible to everyone: GitHub never showed the release tag as Verified, and could not, because +# marking a signature Verified requires THREE things to agree (docs: "Associating an email with your GPG +# key"): the tagger's email, an email in a uid of the registered key, and a verified email on the account. +# A key with no email fails the second forever. GitHub's own API reported the key as `emails=` — empty. +# +# Using the maintainer's address here does blunt one edge of the separation this file argues for, so state +# what actually keeps the two keys distinguishable now: the uid NAME says out loud that this is a CI key, +# the key EXPIRES after a year, and it is certified by the hardware key rather than merely asserted. What +# it no longer provides is separation by address — and it never could, because GitHub offers no way to +# verify a tag signed by a key bearing an address that is not yours. +# +# READ from the maintainer key, never written here. The project publishes no contact address anywhere (see +# CHANGELOG 5.0.0), and a committed script is published: hardcoding it would put the address into the +# repository, into every clone, and into the search index — undoing that decision to save one lookup. +# It also cannot drift, since the address that must match is by definition the one on the key. +maintainer_fpr="${CI_KEY_FROM:-$(git config --get user.signingkey || true)}" +[ -n "$maintainer_fpr" ] || { + echo "error: git config user.signingkey is unset, so the maintainer key is unknown." >&2 + echo " Set it, or pass the address explicitly: CI_KEY_EMAIL=you@example.com $0" >&2 + exit 1 +} +EMAIL="${CI_KEY_EMAIL:-$(gpg --list-keys --with-colons "$maintainer_fpr" 2>/dev/null \ + | awk -F: '/^uid:/ {print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p')}" +[ -n "$EMAIL" ] || { + echo "error: could not read an email from the maintainer key $maintainer_fpr." >&2 + echo " A CI key without an email can never produce a verified tag — that is what this fixes." >&2 + exit 1 +} + tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT chmod 700 "$tmp" @@ -54,6 +87,7 @@ Key-Type: EDDSA Key-Curve: ed25519 Key-Usage: sign Name-Real: $NAME +Name-Email: $EMAIL Expire-Date: $EXPIRY Passphrase: $passphrase %commit @@ -84,7 +118,7 @@ CI signing key generated. Fingerprint : $fpr Expires : in $EXPIRY (renew or replace before then — see SECURITY.md) - Identity : $NAME + Identity : $NAME <$EMAIL> -------------------------------------------------------------------------------- 1) Add TWO secrets to the 'marketplace' environment @@ -111,7 +145,19 @@ CI signing key generated. gpg --armor --export $fpr > docs/ci-signing-key.asc -------------------------------------------------------------------------------- -4) Never import the PRIVATE half into your keyring. It belongs in exactly one +4) Register the PUBLIC key on your GitHub ACCOUNT, or the release tag will never + show as Verified. This step was missing from earlier versions of this script, + which is exactly how a release shipped with an unverified tag. + + gh auth refresh -s write:gpg_key # one-off, interactive + gh gpg-key add docs/ci-signing-key.asc + gh api user/gpg_keys --jq '.[]|"\(.key_id) \(([.emails[]?.email]|join(",")))"' + + The last command is the check that matters: if the key lists NO emails, the + tag cannot be verified no matter what else is correct. + +-------------------------------------------------------------------------------- +5) Never import the PRIVATE half into your keyring. It belongs in exactly one place — the GitHub environment secret. Keeping it out of your keyring is what stops it quietly becoming a second maintainer identity. ================================================================================ diff --git a/src/main/kotlin/dev/lain/claudejb/process/AccountProfile.kt b/src/main/kotlin/dev/lain/claudejb/process/AccountProfile.kt new file mode 100644 index 00000000..0bcc30c9 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/process/AccountProfile.kt @@ -0,0 +1,118 @@ +package dev.lain.claudejb.process + +import com.intellij.openapi.diagnostic.thisLogger +import dev.lain.claudejb.settings.SecretStore +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.jetbrains.annotations.TestOnly +import java.io.File + +/** + * Who is signed in, read straight from the binary's own config instead of asked for. + * + * The account identity is not something the binary computes or fetches: it sits in `~/.claude.json` under + * `oauthAccount` (`emailAddress`, `organizationName`, `accountUuid`), written at login. The plan name is + * likewise carried inside the credentials blob the plugin already holds in its safe. So the dashboard does + * not need the binary to tell it any of this — reading it directly means the account card is populated + * whatever identity the session ends up running as, on every platform. + * + * The plan-limit windows are a different thing and they do NOT come from here: the binary fetches them from + * the claude.ai usage endpoint and reports them through `get_usage`. It needs the account's OAuth scopes to + * do it (`user:profile`), which is why the account object banked here is also fed to the process — see + * [CredentialsVault.envOverlay]. Passing only the access token is what used to leave the meters dark. + * + * Cached in memory after the first read — this is a small file on a hot path (every dashboard push). + */ +object AccountProfile { + + private val log = thisLogger() + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** `{email, org}` — either may be null. */ + data class Identity(val email: String?, val org: String?) + + @Volatile private var cached: Identity? = null + + @Volatile private var cachedAt = 0L + + /** Test seam, mirroring [CredentialsVault.homeOverride]. */ + @TestOnly + @Volatile + internal var homeOverride: File? = null + + private fun home(): File = homeOverride ?: File(System.getProperty("user.home").orEmpty()) + + /** `~/.claude.json` — the binary's config, which also carries the signed-in account. */ + fun configFile(): File = File(home(), ".claude.json") + + /** Drops the cache, so the next read reflects a fresh sign-in. */ + fun invalidate() { + cached = null + cachedAt = 0 + } + + /** + * The signed-in identity, or null when the file is absent/unreadable or holds no account. + * + * Re-read at most once per [TTL_MS]; a sign-in calls [invalidate] so a change is never waited out. + */ + fun read(): Identity? { + val now = System.currentTimeMillis() + cached?.takeIf { now - cachedAt < TTL_MS }?.let { return it } + val identity = fromSafe() ?: run { + capture() + fromSafe() + } ?: return null + cached = identity + cachedAt = now + return identity + } + + /** + * The WHOLE `oauthAccount` object as the binary wrote it, held in the safe. + * + * Kept entire rather than reduced to the two fields the dashboard shows: it is the binary's own object, + * and a subset would be us deciding which of its fields it is allowed to have. + */ + fun storedAccountJson(): String? = SecretStore.get(SecretStore.ACCOUNT_PROFILE) + + /** The safe is the source of record: it outlives `~/.claude.json` being replaced, moved or wiped. */ + private fun fromSafe(): Identity? = storedAccountJson()?.let { blob -> + runCatching { identityOf(json.parseToJsonElement(blob).jsonObject) }.getOrNull() + } + + private fun identityOf(account: kotlinx.serialization.json.JsonObject): Identity? = Identity( + email = account["emailAddress"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }, + org = account["organizationName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }, + ).takeIf { it.email != null || it.org != null } + + private fun accountObject(): kotlinx.serialization.json.JsonObject? { + val file = configFile() + if (!file.isFile) return null + return runCatching { + json.parseToJsonElement(file.readText()).jsonObject["oauthAccount"]?.jsonObject + }.getOrElse { + log.warn("could not read the account profile from ~/.claude.json", it) + null + } + } + + /** + * Captures the account the binary just wrote and files it in the safe, whole. Called after a sign-in, + * when that file is freshest — from then on the dashboard can name the account without it. + */ + fun capture() { + val account = accountObject() ?: return + runCatching { SecretStore.set(SecretStore.ACCOUNT_PROFILE, account.toString()) } + cached = identityOf(account) + cachedAt = System.currentTimeMillis() + } + + private const val TTL_MS = 60_000L +} diff --git a/src/main/kotlin/dev/lain/claudejb/process/ApiKeyApproval.kt b/src/main/kotlin/dev/lain/claudejb/process/ApiKeyApproval.kt new file mode 100644 index 00000000..994f2c5d --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/process/ApiKeyApproval.kt @@ -0,0 +1,143 @@ +package dev.lain.claudejb.process + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import org.jetbrains.annotations.TestOnly +import java.io.File + +/** + * Records a user-supplied `ANTHROPIC_API_KEY` as approved, in the binary's own `~/.claude.json`. + * + * The binary does not accept an API key from the environment on trust: the first time it sees a new one it + * ASKS, and remembers the answer in `customApiKeyResponses.approved` as the key's last + * [SUFFIX_LENGTH] characters. Our sessions run under `--print`, where there is no one to ask — so an + * unapproved key is refused and the turn fails with an invalid-key error, for a key that works perfectly in + * a terminal where the question was once answered. That was the whole bug: the key was valid and correctly + * delivered; it had simply never been approved for a non-interactive session. + * + * Writing the approval IS answering that question, and the user answered it by typing the key into the + * sign-in card. Only the suffix is stored (it is what the CLI stores, and it is not a usable credential); + * the key itself lives in the IDE's PasswordSafe and reaches the process through the environment. + */ +object ApiKeyApproval { + + private val log = thisLogger() + + /** What the CLI records per approved key — the last 20 characters, not the key. */ + private const val SUFFIX_LENGTH = 20 + + private const val RESPONSES = "customApiKeyResponses" + private const val APPROVED = "approved" + private const val REJECTED = "rejected" + + private val json = Json { ignoreUnknownKeys = true } + private val writer = Json { prettyPrint = true } + + /** Test seam — see [CredentialsVault.homeOverride]; this file is the user's real CLI config. */ + @TestOnly + @Volatile + internal var homeOverride: File? = null + + /** `~/.claude.json` — the CLI's own config, which we amend rather than replace. */ + fun configFile(): File = + File(homeOverride ?: File(System.getProperty("user.home").orEmpty()), ".claude.json") + + /** The identifier the CLI matches on. Short keys are returned whole rather than padded. */ + fun suffixOf(key: String): String = key.takeLast(SUFFIX_LENGTH) + + /** + * Adds [key]'s suffix to the approved list, and removes it from the rejected one so a key that was + * once declined can be re-supplied without editing JSON by hand. + * + * Every other field of the file is preserved verbatim: this is the CLI's config, shared with the user's + * terminal, and clobbering it would be a far worse bug than the one being fixed. Missing or unparseable + * file → we do NOT create or overwrite one; a corrupt read must not become a corrupt write. + * + * @return true when the file now records the approval. + */ + fun approve(key: String): Boolean { + if (inert()) return false + val suffix = suffixOf(key).takeIf { it.isNotBlank() } ?: return false + val file = configFile() + val root = readConfig(file) ?: return false + + val responses = root[RESPONSES] as? JsonObject + val approved = (responses?.get(APPROVED) as? JsonArray).orEmpty() + if (approved.any { it is JsonPrimitive && it.content == suffix }) return true + + val updated = buildJsonObject { + root.forEach { (k, v) -> if (k != RESPONSES) put(k, v) } + put(RESPONSES, withApproval(responses, approved, suffix)) + } + return runCatching { + file.writeText(writer.encodeToString(JsonObject.serializer(), updated)) + true + }.getOrElse { + log.warn("could not record the API key approval", it) + false + } + } + + /** `customApiKeyResponses` with [suffix] added to `approved` and removed from `rejected`. */ + private fun withApproval(responses: JsonObject?, approved: List, suffix: String) = + buildJsonObject { + responses?.forEach { (k, v) -> if (k != APPROVED && k != REJECTED) put(k, v) } + put( + APPROVED, + buildJsonArray { + approved.forEach { add(it) } + add(JsonPrimitive(suffix)) + }, + ) + put( + REJECTED, + buildJsonArray { + (responses?.get(REJECTED) as? JsonArray).orEmpty() + .filterNot { it is JsonPrimitive && it.content == suffix } + .forEach { add(it) } + }, + ) + } + + /** + * Refuses to touch the developer's real `~/.claude.json` from a test JVM — the same rule, and the same + * hard-won reason, as [CredentialsVault.inertHere]: this file is the user's live CLI config. + */ + internal fun inert(): Boolean = + homeOverride == null && ApplicationManager.getApplication()?.isUnitTestMode != false + + /** + * `~/.claude.json` parsed, or null when it is absent or not readable JSON. Shared with [ConsoleApiKey], + * which amends the same file: a corrupt read must never become a corrupt write, and that rule is worth + * having in exactly one place. + */ + internal fun readConfig(): JsonObject? = readConfig(configFile()) + + /** Writes [root] back over `~/.claude.json`, pretty-printed like the CLI does. */ + internal fun writeConfig(root: JsonObject): Boolean = runCatching { + configFile().writeText(writer.encodeToString(JsonObject.serializer(), root)) + true + }.getOrElse { + log.warn("could not write ~/.claude.json", it) + false + } + + private fun readConfig(file: File): JsonObject? { + if (!file.isFile) return null + return runCatching { json.parseToJsonElement(file.readText()).jsonObject }.getOrElse { + log.warn("~/.claude.json is not readable JSON — leaving it untouched", it) + null + } + } + + private fun JsonArray?.orEmpty(): List = this ?: emptyList() +} diff --git a/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt b/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt new file mode 100644 index 00000000..bf51ce95 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/process/AuthCli.kt @@ -0,0 +1,114 @@ +package dev.lain.claudejb.process + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import dev.lain.claudejb.settings.SecretStore +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File + +/** + * The binary's non-interactive auth surface: `claude auth status` and `claude auth logout`. + * + * **`auth status` takes NO `--json` flag — it already answers in JSON.** It was being invoked with one, and + * an unrecognised flag is a non-zero exit, which [run] maps to null: "unknown". So the probe answered nothing + * at all, which is both the login check and the only place the binary states the account identity — that is + * why the dashboard's Email and Organization rows were empty while Plan and Provider (which have other + * sources) filled in. Verified against 2.1.223, the plain command: + * + * ```json + * {"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty", + * "email":"…","orgId":"…","orgName":"…","subscriptionType":"max"} + * ``` + * + * This is what makes login detection PROACTIVE: without it the plugin only learned about a missing login + * when a turn had already failed on it. Both calls are BLOCKING (they run a process) — pooled thread only. + * + * The [env] parameter matters and is not decoration: with credentials held in the IDE's PasswordSafe the + * binary is only authenticated when `CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_API_KEY` are present in its + * environment, so probing without the session's launch env would report logged-out for a session that is + * perfectly signed in. Callers pass [dev.lain.claudejb.session.ClaudeSession.effectiveLaunchEnv]. + */ +object AuthCli { + + /** + * What `auth status --json` reports. **Every field it emits is taken**, not a chosen subset: this is the + * only place the account identity is stated by the binary itself, and the dashboard's account card had + * an empty Organization row for as long as `orgName` was missing from here. Verified against 2.1.223: + * + * ```json + * {"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty", + * "email":"…","orgId":"…","orgName":"…'s Organization","subscriptionType":"max"} + * ``` + * + * Lenient: unknown keys ignored, so a new field is a non-event until it is wanted. + */ + @Serializable + data class AuthState( + val loggedIn: Boolean = false, + val authMethod: String? = null, + val apiProvider: String? = null, + val email: String? = null, + val orgId: String? = null, + val orgName: String? = null, + val subscriptionType: String? = null, + ) + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Null when the probe could not run or its output was not parseable — "unknown", never "logged out". + * + * A successful reply is filed in the IDE safe ([SecretStore.AUTH_STATUS]) exactly as the binary wrote it. + * The probe spawns a process, so it cannot run on every dashboard push; [stored] is what the account card + * reads between probes, and it is the binary's own answer rather than a guess assembled from elsewhere. + */ + fun status(binary: File, env: Map): AuthState? { + // No `--json`: the command answers JSON on its own, and the flag it does not know is a non-zero exit. + val output = run(binary, env, "auth", "status") ?: return null + val state = parse(output) ?: return null + // Filed ONLY when the reply actually names the account. Asked with our own credential in the + // environment the binary answers `authMethod: oauth_token` and omits email/orgName — a perfectly + // valid reply that carries no identity, and letting it overwrite the safe would erase the good one. + // Verbatim, from the first brace: a re-serialization of our data class would silently drop any field + // this version does not model yet. + if (state.email != null || state.orgName != null) { + runCatching { SecretStore.set(SecretStore.AUTH_STATUS, output.substring(output.indexOf('{')).trim()) } + } + return state + } + + /** The last `auth status` reply the safe holds, or null. No process spawn — safe on any thread. */ + fun stored(): AuthState? = SecretStore.get(SecretStore.AUTH_STATUS)?.let(::parse) + + /** The CLI may prefix warnings (update notices) before the JSON object; parse from the first brace. */ + private fun parse(output: String): AuthState? { + val start = output.indexOf('{') + if (start < 0) return null + return runCatching { json.decodeFromString(output.substring(start)) }.getOrNull() + } + + /** True when the logout completed. Clears the BINARY's own credential store, not the IDE's. */ + fun logout(binary: File, env: Map): Boolean = + run(binary, env, "auth", "logout") != null + + /** Runs the binary with [args] and the given env; null on spawn failure, timeout or non-zero exit. */ + private fun run(binary: File, env: Map, vararg args: String): String? { + val output = runCatching { + val cmd = GeneralCommandLine(listOf(binary.absolutePath) + args) + .withEnvironment(env) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) + // destroyOnTimeout: a binary that never answers must not outlive the question. Without it the + // timeout only stops us WAITING — the process and its stream readers stay alive, which surfaced as + // a leaked-thread failure attributed to whichever test ran next. + CapturingProcessHandler(cmd).runProcess(TIMEOUT_MS, true) + }.getOrNull() ?: return null + if (output.isTimeout || output.exitCode != 0) return null + return output.stdout + } + + private const val TIMEOUT_MS = 15_000 +} diff --git a/src/main/kotlin/dev/lain/claudejb/process/BinaryInstall.kt b/src/main/kotlin/dev/lain/claudejb/process/BinaryInstall.kt new file mode 100644 index 00000000..34760060 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/process/BinaryInstall.kt @@ -0,0 +1,241 @@ +package dev.lain.claudejb.process + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.openapi.util.SystemInfo +import java.io.File + +/** + * Everything the "Claude Code was not found" boot card needs: the install commands the host can run for + * the user, and the validation that a user-supplied path really is the `claude` binary. + * + * The plugin still never downloads or bundles the binary (the architecture decision stands): installing + * runs the OFFICIAL installer in the IDE's own terminal, where the user watches every line of it. This + * object only knows which commands exist and how to check the result. + */ +object BinaryInstall { + + /** + * One way of installing Claude Code on this OS. + * + * @param display the exact command, shown next to the button — corporate networks block installers + * (a proxy that strips `curl | bash`, a winget source policy), so the user must be able to READ what + * a button will run, copy it, and take it to whatever route their machine allows. + * @param argv what actually runs, as argv — the first element is the shell/tool itself, so nothing + * here is ever concatenated into a shell string by us. + * @param shell what the copy hint names ("or copy this command to bash / PowerShell / cmd") — the + * interpreter a user pasting [display] by hand needs to be in. + */ + data class Method(val id: String, val label: String, val display: String, val argv: List, val shell: String) + + /** + * The install routes for the CURRENT platform, primary first. Several per OS on purpose: corporate + * environments cut individual methods (blocked script CDNs, package-manager allowlists), so offering + * one route is offering some users none. + */ + fun methods(): List = when { + SystemInfo.isWindows -> windowsMethods() + SystemInfo.isMac -> listOf(officialScript(), brewMethod()) + else -> linuxMethods() + } + + private fun windowsMethods() = listOf( + Method( + id = "ps1", + label = "Install via PowerShell", + display = "irm https://claude.ai/install.ps1 | iex", + argv = listOf( + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "irm https://claude.ai/install.ps1 | iex", + ), + shell = "PowerShell", + ), + Method( + id = "winget", + label = "Install via winget", + display = "winget install Anthropic.ClaudeCode", + argv = listOf("winget", "install", "Anthropic.ClaudeCode"), + shell = "PowerShell or cmd", + ), + Method( + id = "cmd", + label = "Install via cmd", + display = "curl -fsSL https://claude.ai/install.cmd -o install.cmd && install.cmd && del install.cmd", + // %TEMP% rather than the project directory: the transient script must not appear in the + // user's working tree (or their VCS status) while it runs. + argv = listOf( + "cmd.exe", + "/c", + "curl -fsSL https://claude.ai/install.cmd -o \"%TEMP%\\claude-install.cmd\"" + + " && \"%TEMP%\\claude-install.cmd\" && del \"%TEMP%\\claude-install.cmd\"", + ), + shell = "cmd", + ), + ) + + private fun officialScript() = Method( + id = "sh", + label = "Install via the official script", + display = "curl -fsSL https://claude.ai/install.sh | bash", + argv = listOf("/bin/bash", "-lc", "curl -fsSL https://claude.ai/install.sh | bash"), + shell = "bash", + ) + + private fun brewMethod() = Method( + id = "brew", + label = "Install via Homebrew", + display = "brew install --cask claude-code", + // -l so the login profile runs: on Apple-silicon Macs brew lives in /opt/homebrew/bin, + // which a GUI-launched IDE's environment does not have on PATH. + argv = listOf("/bin/bash", "-lc", "brew install --cask claude-code"), + shell = "bash", + ) + + // The distro's own package manager, when one is recognised: Anthropic publishes signed apt, dnf and + // apk repositories, and on a machine that standardises on package-manager updates the script route may + // be exactly what the environment blocks. The `sudo` stays INSIDE the command and the command runs in + // the IDE terminal, where sudo prompts interactively like any shell — no elevation machinery on our + // side, and the user watches every line. + private fun linuxMethods() = buildList { + add(officialScript()) + if (File("/etc/debian_version").isFile) add(aptMethod()) + if (File("/etc/fedora-release").isFile || File("/etc/redhat-release").isFile) add(dnfMethod()) + if (File("/etc/alpine-release").isFile) add(apkMethod()) + } + + // Commands mirror the official install docs verbatim (code.claude.com/docs/en/setup) — key, repo, + // package — joined with && so a failed step stops the chain instead of half-configuring a repo. + private fun aptMethod() = Method( + id = "apt", + label = "Install via apt", + shell = "bash", + display = "sudo install -d -m 0755 /etc/apt/keyrings && " + + "sudo curl -fsSL https://downloads.claude.ai/keys/claude-code.asc -o /etc/apt/keyrings/claude-code.asc && " + + "echo \"deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main\" | " + + "sudo tee /etc/apt/sources.list.d/claude-code.list && sudo apt update && sudo apt install claude-code", + argv = listOf( + "/bin/bash", + "-lc", + buildString { + append("sudo install -d -m 0755 /etc/apt/keyrings") + append(" && sudo curl -fsSL https://downloads.claude.ai/keys/claude-code.asc") + append(" -o /etc/apt/keyrings/claude-code.asc") + append(" && echo \"deb [signed-by=/etc/apt/keyrings/claude-code.asc]") + append(" https://downloads.claude.ai/claude-code/apt/stable stable main\"") + append(" | sudo tee /etc/apt/sources.list.d/claude-code.list") + append(" && sudo apt update && sudo apt install claude-code") + }, + ), + ) + + private fun dnfMethod(): Method { + // printf rather than a heredoc: this string is already inside `bash -lc "…"`, and a heredoc nested + // in a quoted argv element is exactly the kind of quoting puzzle argv exists to avoid. + val repo = "[claude-code]\\nname=Claude Code\\nbaseurl=https://downloads.claude.ai/claude-code/rpm/stable\\n" + + "enabled=1\\ngpgcheck=1\\ngpgkey=https://downloads.claude.ai/keys/claude-code.asc" + return Method( + id = "dnf", + label = "Install via dnf", + shell = "bash", + display = "sudo tee /etc/yum.repos.d/claude-code.repo (repo config) && sudo dnf install claude-code", + argv = listOf( + "/bin/bash", + "-lc", + "printf '$repo\\n' | sudo tee /etc/yum.repos.d/claude-code.repo && sudo dnf install claude-code", + ), + ) + } + + private fun apkMethod() = Method( + id = "apk", + label = "Install via apk", + shell = "sh (as root)", + // As documented: Alpine's docs assume a root shell (sudo is often absent, doas varies). + display = "wget -O /etc/apk/keys/claude-code.rsa.pub https://downloads.claude.ai/keys/claude-code.rsa.pub && " + + "echo \"https://downloads.claude.ai/claude-code/apk/stable\" >> /etc/apk/repositories && apk add claude-code", + argv = listOf( + "/bin/sh", + "-lc", + "wget -O /etc/apk/keys/claude-code.rsa.pub https://downloads.claude.ai/keys/claude-code.rsa.pub" + + " && echo \"https://downloads.claude.ai/claude-code/apk/stable\" >> /etc/apk/repositories" + + " && apk add claude-code", + ), + ) + + fun method(id: String): Method? = methods().firstOrNull { it.id == id } + + /** Outcome of validating a user-supplied path. [Invalid.reason] is shown verbatim on the boot card. */ + sealed interface Validation { + data class Ok(val binary: File, val version: String) : Validation + data class Invalid(val reason: String) : Validation + } + + /** + * Checks that [rawPath] is the `claude` binary — not merely a file that exists. + * + * Accepts a directory too (the installer's bin dir), resolving the platform's executable names inside + * it, because "where is Claude installed?" is the question users can actually answer. The identity + * check runs `--version` and requires the output to name Claude Code: an existence check alone would + * accept any executable, and the failure would then surface later as an opaque protocol error on a + * process that was never `claude` at all. + * + * BLOCKING (runs a process, seconds on a cold start) — call from a pooled thread, never the EDT. + */ + fun validate(rawPath: String): Validation { + val trimmed = rawPath.trim().removeSurrounding("\"") + if (trimmed.isBlank()) return Validation.Invalid("Enter a path first.") + var file = File(trimmed) + if (file.isDirectory) { + file = candidatesIn(file).firstOrNull { it.isFile } + ?: return Validation.Invalid("No claude executable inside that directory.") + } + if (!file.isFile) return Validation.Invalid("That file does not exist.") + if (!file.canExecute() && !SystemInfo.isWindows) { + return Validation.Invalid("That file is not executable (chmod +x?).") + } + return probeIdentity(file) + } + + /** Runs `--version` on an existing executable and demands the answer name Claude Code. */ + private fun probeIdentity(file: File): Validation { + // A Windows npm shim cannot be probed through cmd.exe (see ClaudeBinaryLocator.resolveNodeScript); + // probe the underlying cli.js through node exactly the way ClaudeProcess launches it. + val script = ClaudeBinaryLocator.resolveNodeScript(file) + val argv = if (script != null) { + listOf(ClaudeBinaryLocator.locateNode(near = file), script.absolutePath, "--version") + } else { + listOf(file.absolutePath, "--version") + } + + val output = runCatching { + val cmd = GeneralCommandLine(argv) + .withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.CONSOLE) + CapturingProcessHandler(cmd).runProcess(VERSION_PROBE_TIMEOUT_MS) + }.getOrElse { e -> + return Validation.Invalid("Could not run it: ${e.message}") + } + + if (output.isTimeout) return Validation.Invalid("It did not answer --version within 15s.") + val text = (output.stdout + output.stderr).trim() + // The real binary identifies itself, e.g. "2.1.222 (Claude Code)". Matching on the NAME rather + // than on a version shape is deliberate: any executable can print digits. + if (!text.contains("claude code", ignoreCase = true)) { + val head = text.lineSequence().firstOrNull()?.take(ERROR_HEAD_CHARS).orEmpty().ifBlank { "no output" } + return Validation.Invalid("That runs, but it isn't Claude Code ($head).") + } + return Validation.Ok(file, text.lineSequence().first().trim()) + } + + private fun candidatesIn(dir: File): List = + (if (SystemInfo.isWindows) listOf("claude.exe", "claude.cmd", "claude.bat") else listOf("claude")) + .map { File(dir, it) } + + private const val VERSION_PROBE_TIMEOUT_MS = 15_000 + + /** How much of a stranger executable's first output line the rejection message quotes. */ + private const val ERROR_HEAD_CHARS = 80 +} diff --git a/src/main/kotlin/dev/lain/claudejb/process/ClaudeLoginFlow.kt b/src/main/kotlin/dev/lain/claudejb/process/ClaudeLoginFlow.kt index adc3f6ac..d69dda8e 100644 --- a/src/main/kotlin/dev/lain/claudejb/process/ClaudeLoginFlow.kt +++ b/src/main/kotlin/dev/lain/claudejb/process/ClaudeLoginFlow.kt @@ -23,6 +23,12 @@ class ClaudeLoginFlow( private val binaryPath: String, private val cwd: String?, private val env: Map, + /** + * The subcommand to drive. `auth login` writes to the binary's own credential store; + * `setup-token` prints a long-lived token instead (surfaced via [Listener.onToken]) so the caller can + * keep it in the IDE's PasswordSafe and the binary's store stays empty. + */ + private val args: List = listOf("auth", "login"), ) { private companion object { @@ -44,6 +50,12 @@ class ClaudeLoginFlow( /** The binary is now waiting for the authorization code on stdin (prompt the user, then [submitCode]). */ fun onCodeRequested() + /** + * A `setup-token` flow printed its long-lived token. Fired at most once, before [onResult]. The + * value is a SECRET: store it (PasswordSafe) and nothing else — no logs, no transcript, no UI text. + */ + fun onToken(token: String) {} + /** The flow ended: [success] from the exit code (and a final-output sanity check), with a short [message]. */ fun onResult(success: Boolean, message: String) } @@ -56,6 +68,8 @@ class ClaudeLoginFlow( @Volatile private var promptSeen = false + @Volatile private var tokenSeen = false + @Volatile private var finished = false /** @@ -63,7 +77,7 @@ class ClaudeLoginFlow( * caller can fall back, e.g. to the IDE terminal) if the process can't be started. Safe to call off the EDT. */ fun start(listener: Listener): Boolean { - val builder = PtyProcessBuilder(arrayOf(binaryPath, "auth", "login")) + val builder = PtyProcessBuilder((listOf(binaryPath) + args).toTypedArray()) .setEnvironment(env) // pty4j replaces the env wholesale — [env] must already carry the base .setInitialColumns(PTY_COLUMNS) // wide enough that the OAuth URL is emitted on a single line .setInitialRows(PTY_ROWS) @@ -82,7 +96,15 @@ class ClaudeLoginFlow( return true } - /** Reads the PTY until EOF, firing URL/prompt signals, then resolves the result from the exit code. */ + /** + * Reads the PTY until EOF, firing URL/prompt signals, then resolves the result from the **exit code**. + * + * `claude auth login` is a one-shot command: it prints "Login successful." and EXITS 0 on its own (checked + * against 2.1.223, both `--claudeai` and `--console`). So there is nothing to answer and nothing to kill — + * the process ending IS the signal, and its status IS the verdict. Anything this reader writes into that + * PTY, or any kill it issues to hurry the process along, can only turn a clean 0 into something else and + * make a login that worked look like one that failed. + */ private fun pump(proc: PtyProcess, listener: Listener) { val acc = StringBuilder() val buf = ByteArray(READ_BUFFER_BYTES) @@ -103,11 +125,20 @@ class ClaudeLoginFlow( promptSeen = true listener.onCodeRequested() } + if (!tokenSeen) { + LoginOutputParser.extractSetupToken(text)?.let { token -> + tokenSeen = true + listener.onToken(token) + } + } } }.onFailure { log.debug("login PTY reader stopped", it) } val exit = runCatching { proc.waitFor() }.getOrDefault(-1) val out = acc.toString() + // The whole PTY transcript, ANSI stripped and tokens masked — without it a login regression leaves + // nothing behind but an exit code, which is how this one stayed invisible. + log.debug("claude login finished (exit=$exit):\n${LoginOutputParser.redactSecrets(out)}") val success = exit == 0 && !LoginOutputParser.looksLikeFailure(out) finish(listener, success, LoginOutputParser.resultMessage(out, success)) } @@ -118,12 +149,19 @@ class ClaudeLoginFlow( listener.onResult(success, message) } - /** Writes the authorization [code] (plus a newline) to the binary's stdin. No-op if the process is gone. */ + /** + * Writes the authorization [code] to the binary's stdin, terminated with a CARRIAGE RETURN. + * + * `\r`, not `\n`, and it is the difference between working and hanging: the login TUI (Ink) puts the + * TTY in raw mode, where the Enter key arrives as `\r` — that is what its input handler maps to + * "submit". A trailing `\n` left the code sitting in the input field with the flow waiting forever, + * which the user experienced as the card stuck on "Verifying". + */ fun submitCode(code: String) { val proc = process ?: return runCatching { proc.outputStream.apply { - write((code.trim() + "\n").toByteArray(StandardCharsets.UTF_8)) + write((code.trim() + "\r").toByteArray(StandardCharsets.UTF_8)) flush() } }.onFailure { log.warn("Failed to write the login code to the PTY", it) } diff --git a/src/main/kotlin/dev/lain/claudejb/process/ConsoleApiKey.kt b/src/main/kotlin/dev/lain/claudejb/process/ConsoleApiKey.kt new file mode 100644 index 00000000..155d38f9 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/process/ConsoleApiKey.kt @@ -0,0 +1,59 @@ +package dev.lain.claudejb.process + +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * Takes custody of the API key that `claude auth login --console` mints for itself. + * + * The Console sign-in is the route an ORGANISATION wants: the OAuth consent it requests includes + * `org:create_api_key`, so the binary creates a key for Claude Code on the user's behalf ("Creating API key + * for Claude Code…") instead of anyone pasting one around. What it then does with that key is the problem + * this class exists for — verified against 2.1.223's own code: + * + * ```js + * await wr(n => ({ ...n, primaryApiKey: e, customApiKeyResponses: { …approved: [...] } })) + * ``` + * + * i.e. it writes the key **in clear text** into `~/.claude.json` (the macOS Keychain branch exists in the + * binary but is switched off) — a long-lived billing credential in a file readable by anything running as the + * user, shared with every terminal on the machine. That is precisely what [CredentialsVault] exists to stop + * for the subscription login, so the Console login gets the same treatment: [harvest] reads the key, strips it + * out of the file, and hands it back for the caller to file in the IDE's PasswordSafe. + * + * The cost is stated rather than hidden, exactly as it is for the subscription vault: the user's own terminal + * CLI loses that key, because it was never the plugin's to leave lying there. Signing in again in a terminal + * mints another one. + */ +object ConsoleApiKey { + + private val log = thisLogger() + + private const val PRIMARY_API_KEY = "primaryApiKey" + + /** + * The Console-minted key, removed from `~/.claude.json` on the way out — or null when there is none, the + * file is absent/unparseable, or we are inert (a test JVM pointed at a real home). + * + * The removal is an AMENDMENT: every other field of the CLI's config is preserved verbatim + * ([ApiKeyApproval.readConfig]/[ApiKeyApproval.writeConfig]), because this is the user's shared config and + * clobbering it would be a worse bug than the one being fixed. If the write fails the key is NOT returned: + * better to leave the credential where the binary put it than to report it vaulted while a copy stays on + * disk under a name nothing will ever clean up. + */ + fun harvest(): String? { + if (ApiKeyApproval.inert()) return null + val root = ApiKeyApproval.readConfig() ?: return null + val key = root[PRIMARY_API_KEY]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null + val stripped = buildJsonObject { + root.forEach { (k, v) -> if (k != PRIMARY_API_KEY) put(k, v) } + } + if (!ApiKeyApproval.writeConfig(stripped)) { + log.warn("could not strip $PRIMARY_API_KEY from ~/.claude.json — leaving the key where the binary put it") + return null + } + return key + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt b/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt new file mode 100644 index 00000000..3b66bef3 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt @@ -0,0 +1,242 @@ +package dev.lain.claudejb.process + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import dev.lain.claudejb.settings.SecretStore +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import org.jetbrains.annotations.TestOnly +import java.io.File + +/** + * Keeps the subscription login OFF the disk, full stop. + * + * The full-consent OAuth flow (`claude auth login`) is the only one that grants the scopes Claude Code + * exercises — file upload for pasted attachments among them — and it writes its credentials to + * `~/.claude/.credentials.json`. That file is the CLI's own store: plaintext JSON on Linux, readable by + * anything running as the user, and shared with every terminal session on the machine. + * + * So the plugin does not leave it there. [harvest] moves the file's contents into the IDE's PasswordSafe + * (OS keychain / KWallet / DPAPI) and DELETES it — including a login the user made in their terminal, + * which is deliberate: the plugin's identity is what it holds securely, and leaving a copy in plaintext + * would defeat the whole exercise. Whoever wants the CLI signed in too can sign it in again. + * + * The credential is then fed back through the ENVIRONMENT, not the disk: [envOverlay] hands the binary + * `CLAUDE_CODE_OAUTH_TOKEN`, which takes precedence over its own store (verified against 2.1.222 — + * `auth status` flips `authMethod` from `claude.ai` to `oauth_token` when it is set). `/proc//environ` + * is 0400; the file was world-readable-by-the-user, so this is strictly narrower AND leaves nothing behind + * once the process exits. + * + * **The file is never written back. Not once, not briefly, not at 0600.** Handing the credential back in + * plaintext is the exact thing this class exists to stop, and a rule with an exception for the awkward case + * is not a rule — the awkward case is where it would have mattered. + * + * **`~/.claude/.credentials.json` is the ONE file this plugin is allowed to delete, and this is the only + * class allowed to delete it** — enforced by `NoFileDeletionContractTest`, which exists because a recursive + * delete in the (now removed) session config dir followed symlinks into `~/.claude` and destroyed a user's + * conversations, skills and session history. Nothing else on their disk is ours to remove. + * + * The cost is stated rather than hidden: only the binary can spend the refresh token, and it does that by + * rewriting its own file. With no file it cannot, so when the access token expires the credential is simply + * spent and the sign-in card comes back. A periodic sign-in is the price of never having a bearer token + * sitting in a world-readable-by-the-user file. + */ +object CredentialsVault { + + private val log = thisLogger() + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * How long before expiry a token stops counting as an identity. Starting a session on a token that dies + * a minute later means a turn failing mid-flight; asking for a sign-in first is the honest version of + * the same outcome. + */ + private const val EXPIRY_MARGIN_MS = 10 * 60 * 1000L + + // The rest of the credential's env surface. Verified present in the shipped CLI's own env registry + // (`sdk.mjs`/`bridge.mjs` name them, and sdk.mjs lists the OAuth ones in its subprocess passthrough). + // `SecretStore.OAUTH_TOKEN` carries the access token itself. + private const val ENV_REFRESH_TOKEN = "CLAUDE_CODE_OAUTH_REFRESH_TOKEN" + private const val ENV_SCOPES = "CLAUDE_CODE_OAUTH_SCOPES" + private const val ENV_SUBSCRIPTION_TYPE = "CLAUDE_CODE_SUBSCRIPTION_TYPE" + private const val ENV_RATE_LIMIT_TIER = "CLAUDE_CODE_RATE_LIMIT_TIER" + private const val ENV_ACCOUNT_UUID = "CLAUDE_CODE_ACCOUNT_UUID" + private const val ENV_ORGANIZATION_UUID = "CLAUDE_CODE_ORGANIZATION_UUID" + private const val ENV_USER_EMAIL = "CLAUDE_CODE_USER_EMAIL" + + /** + * Overridable BY TESTS ONLY, and not a nicety: these operations MOVE a real credential, so a test run + * against the developer's own home that died between harvest and restore would sign them out for real. + * Production never sets it. + */ + @TestOnly + @Volatile + internal var homeOverride: File? = null + + /** `~/.claude/.credentials.json` — the path the binary writes and reads. */ + fun credentialsFile(): File = + File(homeOverride ?: File(System.getProperty("user.home").orEmpty()), ".claude/.credentials.json") + + /** + * Hard refusal to touch a real home from a test JVM, and it is here because it already went wrong: + * the integration tests start a real [dev.lain.claudejb.session.ClaudeSession], whose `launch()` calls + * [harvest] — which read the developer's own credentials, filed them in a throwaway test PasswordSafe + * and deleted them. It was invisible while `launch()` still wrote the file back afterwards, and + * destroyed a live login the moment it stopped doing so. + * + * So in unit-test mode the vault does nothing unless a test has explicitly pointed [homeOverride] at a + * directory of its own. `getApplication()` is null in a pure-JVM test, which is also not a place to be + * moving credentials around. + */ + private fun inertHere(): Boolean { + if (homeOverride != null) return false + return ApplicationManager.getApplication()?.isUnitTestMode ?: true + } + + /** + * The launch environment's share of the credential: **the whole vaulted blob, field by field**, so the + * binary authenticates with nothing on disk AND with the identity it actually wrote at login. + * + * THE BUG THIS FIXES, because it cost a user their session history before it was understood: only + * `accessToken` used to be handed over. A bare access token leaves the binary without the OAuth + * **scopes**, and the SDK is explicit about the consequence — `SDKControlGetUsageResponse` documents + * `rate_limits_available` as *"False when plan rate limits do not apply (API key, Bedrock, Vertex, or + * **missing profile scope**)"*. The stored blob grants `user:profile`; the env did not say so, so + * `get_usage` answered `rate_limits: null` and every session meter went dark. That was misdiagnosed as + * "the binary only reports this from its own config directory", which produced a relocated + * `CLAUDE_CONFIG_DIR` full of symlinks into `~/.claude` and a recursive delete that emptied it. The + * directory was never needed: the binary reads all of this from the environment. + * + * Mapping, from the file the CLI writes (`claudeAiOauth`) to the names the binary reads: + * + * ``` + * accessToken -> CLAUDE_CODE_OAUTH_TOKEN + * refreshToken -> CLAUDE_CODE_OAUTH_REFRESH_TOKEN + * scopes[] -> CLAUDE_CODE_OAUTH_SCOPES (space-separated, the OAuth `scope` encoding) + * subscriptionType -> CLAUDE_CODE_SUBSCRIPTION_TYPE + * rateLimitTier -> CLAUDE_CODE_RATE_LIMIT_TIER + * ``` + * + * plus the account the same login wrote to `~/.claude.json`, held whole in the safe by [AccountProfile]: + * `accountUuid` → `CLAUDE_CODE_ACCOUNT_UUID`, `organizationUuid` → `CLAUDE_CODE_ORGANIZATION_UUID`, + * `emailAddress` → `CLAUDE_CODE_USER_EMAIL`. + * + * Absent fields are simply omitted — never blanked. An empty env var is a value, and a blank scope list + * or subscription would be us telling the binary something false about the account. + * + * `CLAUDE_CODE_SDK_HAS_OAUTH_REFRESH` is deliberately NOT set: it announces that the HOST will refresh + * the token, and this host cannot (only the binary can spend a refresh token). Claiming it would leave + * an expiry with nobody handling it. + * + * Empty when the safe holds nothing, when the blob does not parse, when the token is at or within + * [EXPIRY_MARGIN_MS] of expiry, or when [existing] already names a credential: an API key or a token + * written by hand in Settings outranks anything we harvested. + */ + fun envOverlay(existing: Set): Map { + if (SecretStore.OAUTH_TOKEN in existing || SecretStore.API_KEY in existing) return emptyMap() + val token = usableToken() ?: return emptyMap() + val oauth = oauthNode() ?: return emptyMap() + val env = mutableMapOf(SecretStore.OAUTH_TOKEN to token) + oauth.string("refreshToken")?.let { env[ENV_REFRESH_TOKEN] = it } + oauth.strings("scopes")?.takeIf { it.isNotEmpty() }?.let { env[ENV_SCOPES] = it.joinToString(" ") } + oauth.string("subscriptionType")?.let { env[ENV_SUBSCRIPTION_TYPE] = it } + oauth.string("rateLimitTier")?.let { env[ENV_RATE_LIMIT_TIER] = it } + accountNode()?.let { account -> + account.string("accountUuid")?.let { env[ENV_ACCOUNT_UUID] = it } + account.string("organizationUuid")?.let { env[ENV_ORGANIZATION_UUID] = it } + account.string("emailAddress")?.let { env[ENV_USER_EMAIL] = it } + } + return env + } + + /** A non-blank string field, or null — so an absent field is omitted rather than sent as `""`. */ + private fun kotlinx.serialization.json.JsonObject.string(name: String): String? = + this[name]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + + /** A string-array field (`scopes`), or null. Non-string entries are dropped rather than stringified. */ + private fun kotlinx.serialization.json.JsonObject.strings(name: String): List? = + (this[name] as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) } + + /** The `oauthAccount` object [AccountProfile] banked at sign-in, or null. */ + private fun accountNode() = AccountProfile.storedAccountJson()?.let { blob -> + runCatching { json.parseToJsonElement(blob).jsonObject }.getOrNull() + } + + /** + * Whether the vault holds a subscription credential that can still authenticate a session. + * + * An EXPIRED blob deliberately answers false: it cannot be refreshed without writing the file back, so + * it is not an identity any more. Callers treat that as signed-out and show the card, which beats + * launching a session that will fail its first turn. + */ + fun hasUsableToken(): Boolean = usableToken() != null + + /** + * The plan name recorded in the vaulted blob (`max`, `pro`, …), or null. + * + * Needed because authenticating through `CLAUDE_CODE_OAUTH_TOKEN` gives the binary a REDUCED identity: + * `auth status` then answers with `authMethod`/`apiProvider` and nothing else — no email, no plan — + * where the same account read from its own file answers with all of it. Verified against 2.1.222. The + * blob we hold carries the plan, so the dashboard need not lose that much. + */ + fun subscriptionType(): String? = oauthNode()?.get("subscriptionType")?.jsonPrimitive?.contentOrNull + ?.takeIf { it.isNotBlank() } + + /** + * The stored access token, or null when it is absent, unparseable or too close to expiry to be worth + * using. Parsed leniently: the blob is the binary's private format and may grow fields. + */ + private fun oauthNode() = SecretStore.get(SecretStore.CREDENTIALS_JSON)?.let { blob -> + runCatching { json.parseToJsonElement(blob).jsonObject["claudeAiOauth"]?.jsonObject }.getOrNull() + } + + private fun usableToken(): String? { + val oauth = oauthNode() ?: return null + val token = oauth["accessToken"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null + val expiresAt = oauth["expiresAt"]?.jsonPrimitive?.longOrNull ?: return null + return token.takeIf { expiresAt - System.currentTimeMillis() > EXPIRY_MARGIN_MS } + } + + /** + * Moves the credentials file into the safe and deletes it. No-op when the file is absent (nothing to + * harvest) or blank (a half-written file is not a credential worth keeping). + * + * @return true when something was taken into the safe. + */ + fun harvest(): Boolean { + if (inertHere()) return false + val file = credentialsFile() + if (!file.isFile) return false + val text = runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } + if (text == null) { + log.warn("credentials file present but unreadable/empty — leaving it alone") + return false + } + SecretStore.set(SecretStore.CREDENTIALS_JSON, text) + // Overwrite before unlinking: on a journalling filesystem the blocks may survive a bare delete, + // and this content is a bearer credential. + runCatching { + file.writeText(" ".repeat(text.length)) + file.delete() + }.onFailure { log.warn("could not remove the credentials file after harvesting", it) } + return true + } + + /** Wipes both halves: the safe entry and any file on disk. Used by Log out. */ + fun clear() { + if (inertHere()) return + SecretStore.clear(SecretStore.CREDENTIALS_JSON) + val file = credentialsFile() + if (file.isFile) { + runCatching { file.delete() }.onFailure { log.warn("could not delete the credentials file", it) } + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/process/LoginOutputParser.kt b/src/main/kotlin/dev/lain/claudejb/process/LoginOutputParser.kt index bb333a4b..fc1140e1 100644 --- a/src/main/kotlin/dev/lain/claudejb/process/LoginOutputParser.kt +++ b/src/main/kotlin/dev/lain/claudejb/process/LoginOutputParser.kt @@ -23,7 +23,8 @@ object LoginOutputParser { // ("Paste" + "code" + "here" → "pastecodehere"). Normalizing both sides makes the match layout-agnostic. private val CODE_PROMPT_HINTS = listOf("pastecodehere", "pastethecode", "enterthecode", "enteryourcode") private val SUCCESS_HINTS = listOf("loginsuccessful", "loggedin", "successfully", "youreallset", "authenticated") - private val FAILURE_HINTS = listOf("invalidcode", "loginfailed", "authenticationfailed", "didnotmatch", "expired", "error") + private val FAILURE_HINTS = + listOf("invalidcode", "loginfailed", "authenticationfailed", "oautherror", "didnotmatch", "expired", "error") /** Removes ANSI escapes so the remaining text can be matched/grepped. */ fun stripAnsi(text: String): String = ANSI.replace(text, "") @@ -46,16 +47,51 @@ object LoginOutputParser { return FAILURE_HINTS.any { it in t } } + /** + * The long-lived token `claude setup-token` prints on success, or null while it hasn't appeared. + * + * Matched by its documented shape — an `sk-ant-` prefixed token — rather than by the surrounding prose, + * which the Ink renderer rearranges freely. Deliberately the LAST match: the flow may echo example or + * placeholder text before printing the real one. NB the caller must treat the containing buffer as a + * secret from this point on: never log it, never put it in a transcript or an error message. + */ + fun extractSetupToken(text: String): String? = + SETUP_TOKEN.findAll(stripAnsi(text)).lastOrNull()?.value + + private val SETUP_TOKEN = Regex("sk-ant-[A-Za-z0-9_\\-]{20,}") + + /** + * The login output with every `sk-ant-…` token masked and the ANSI stripped — the ONLY form in which any + * of this may leave the process (a log line, a notification, the card). A diagnostic that cannot be + * written safely does not get written, and a login regression with no trace is invisible: this is what + * makes both possible at once. + */ + fun redactSecrets(text: String): String = SETUP_TOKEN.replace(stripAnsi(text), "sk-ant-…") + /** * A short, human-facing result line distilled from the final output. On success returns a confirmation; on * failure tries to surface the binary's own error wording, falling back to a generic retry message. */ fun resultMessage(text: String, success: Boolean): String { - val lines = stripAnsi(text).lines().map { it.trim() }.filter { it.isNotEmpty() } + // Whatever line is surfaced, a token must never ride along in it: the message goes to + // notifications and the sign-in card, which are exactly the places a secret must not appear. + val lines = redactSecrets(text).lines().map { it.trim() }.filter { it.isNotEmpty() } if (success) { - return lines.lastOrNull { l -> SUCCESS_HINTS.any { it in normalize(l) } } ?: "You're signed in." + return lines.lastOrNull { l -> SUCCESS_HINTS.any { it in normalize(l) } }?.let(::withoutKeyPrompt) + ?: "You're signed in." } - return lines.lastOrNull { l -> FAILURE_HINTS.any { it in normalize(l) } } + return lines.lastOrNull { l -> FAILURE_HINTS.any { it in normalize(l) } }?.let(::withoutKeyPrompt) ?: "Login failed. Please try again." } + + /** + * Drops the TUI's trailing "Press Enter to continue…" / "Press any key to close." instruction. + * + * Those screens are terminal-only furniture and the plugin answers them itself; repeating them in an IDE + * notification would tell the user to press a key on a terminal they never saw. + */ + private fun withoutKeyPrompt(line: String): String = + line.replace(KEY_PROMPT, "").trim().trimEnd(',', ';', '·', '-').trim().ifEmpty { line } + + private val KEY_PROMPT = Regex("\\bPress\\s+(Enter|any other key|any key)\\b[^.]*\\.?", RegexOption.IGNORE_CASE) } diff --git a/src/main/kotlin/dev/lain/claudejb/process/TerminalLauncher.kt b/src/main/kotlin/dev/lain/claudejb/process/TerminalLauncher.kt index 18f0e420..fc1a3a26 100644 --- a/src/main/kotlin/dev/lain/claudejb/process/TerminalLauncher.kt +++ b/src/main/kotlin/dev/lain/claudejb/process/TerminalLauncher.kt @@ -34,27 +34,28 @@ object TerminalLauncher { * path** (double-quoted for spaces). Using the full path — not the bare name — means a GUI IDE that didn't * inherit the user's login `$PATH` still launches the right binary in the terminal. * - * The subcommand is `auth login` (verified against the binary's `--help`): there is NO top-level `claude - * login`, so sending `claude login` would treat "login" as a *prompt* and start an interactive session - * instead of the OAuth flow. + * [args] is the sign-in's own subcommand, supplied by the caller (`auth login`, plus `--console`/`--sso` + * for the Console and SSO routes — see `LoginCoordinator.Mode`). It defaults to the plain subscription + * login. Note there is NO top-level `claude login` (verified against the binary's `--help`): sending that + * would treat "login" as a *prompt* and start an interactive session instead of the OAuth flow. * * Shell quoting: on **Windows** the IDE terminal is PowerShell, which needs the call operator `&` to execute * a quoted path — without it `"C:\...\claude.exe" auth login` is parsed as a string literal and just echoed. * POSIX shells (bash/zsh) run a quoted path directly, and a leading `&` would background it, so only Windows * gets the prefix. Pure → unit-testable. + * + * This is the LAST-RESORT form: it is text for the user to run by hand. The terminal tab itself is handed + * an argv list ([openAndRunCommand]), which has no quoting problem class at all. */ - fun loginCommand(binaryPath: String, isWindows: Boolean = SystemInfo.isWindows): String { - val quoted = "\"$binaryPath\" auth login" + fun loginCommand( + binaryPath: String, + args: List = listOf("auth", "login"), + isWindows: Boolean = SystemInfo.isWindows, + ): String { + val quoted = (listOf("\"$binaryPath\"") + args).joinToString(" ") return if (isWindows) "& $quoted" else quoted } - /** - * The login flow as an **argv list** — the form [openAndRunCommand] hands straight to the terminal as the tab's - * process. Passing argv instead of a shell string removes the entire quoting problem class ([loginCommand]'s - * PowerShell `&` prefix, paths with spaces) and the shell-startup race, because no shell parses it. Pure. - */ - fun loginArgv(binaryPath: String): List = listOf(binaryPath, "auth", "login") - /** * Opens a terminal tab in the project root that **runs [argv] as the tab's own process**. Must be called on * the EDT. Returns false (so the caller can fall back) when the Terminal plugin is unavailable or every API @@ -75,7 +76,7 @@ object TerminalLauncher { * any deprecation churn and means a future rename degrades to the fallback instead of a `NoSuchMethodError`). * * Passing [argv] as the tab's `shellCommand` also removes two bug classes the old string-command path had: - * no shell parses it (so no quoting hazard — see [loginArgv] vs [loginCommand]), and there is no + * no shell parses it (so no quoting hazard — contrast [loginCommand], which must quote), and there is no * send-text-into-a-shell race to lose the command to. */ fun openAndRunCommand(project: Project, argv: List, tabName: String): Boolean { diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt index 842497c9..eece1187 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/Protocol.kt @@ -277,7 +277,7 @@ data class RateLimitInfo( val status: String = "allowed", // allowed | allowed_warning | rejected val resetsAt: Long? = null, // epoch seconds when this window resets val rateLimitType: String? = null, // five_hour | seven_day | seven_day_opus/sonnet | overage - val utilization: Double? = null, // % of quota used (0..100, sometimes 0..1) + val utilization: Double? = null, // FRACTION of quota used, 0..1 — see utilizationPercent() val overageStatus: String? = null, val isUsingOverage: Boolean = false, // Both of these ARE on the wire and were previously dropped — confirmed by capturing a live @@ -287,10 +287,24 @@ data class RateLimitInfo( val overageInUse: Boolean = false, val surpassedThreshold: Double? = null, ) { - /** Normalized 0..100 percent, or null if the binary didn't report utilization. */ - fun utilizationPercent(): Int? = utilization?.let { - (if (it <= 1.0) it * 100 else it).toInt().coerceIn(0, 100) - } + /** + * Clamped 0..100 percent, or null if the binary didn't report utilization. + * + * **The event's scale is a 0..1 FRACTION, and it is not the same as `get_usage`'s.** Captured live from + * `claude` 2.1.223 while claude.ai reported 92% of the weekly window spent: + * + * ``` + * {"status":"allowed_warning","rateLimitType":"seven_day","utilization":0.92,"surpassedThreshold":0.75} + * ``` + * + * `surpassedThreshold: 0.75` is the corroborating detail — thresholds are announced at 75%/85%, so the + * companion field is unambiguously a fraction too. `sdk.d.ts` documents "Percentage of the window used, + * 0-100" ONLY on the `get_usage` windows ([UsageWindow]); `SDKRateLimitInfo.utilization` carries no + * such note, and the two really do differ. Reading the event on the 0..100 scale rendered a window at + * 92% as **1%** — a quota bar that is not merely wrong but reassuring while the limit is about to hit. + */ + fun utilizationPercent(): Int? = + utilization?.let { Math.round(it * PERCENT).toInt().coerceIn(0, 100) } val isWarning: Boolean get() = status == "allowed_warning" || status == "rejected" val isExhausted: Boolean get() = status == "rejected" @@ -309,6 +323,9 @@ data class RateLimitInfo( } companion object { + /** The event's fraction → percent. [UsageWindow] needs no such factor: it is already 0..100. */ + private const val PERCENT = 100 + /** * DESCRIPTIVE label for a window, for the usage panel — where each bar needs to say what it measures. * Deliberately separate from [windowLabel]: the pill is space-constrained and the panel is not, and diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index 65186ae2..9b2b82b7 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -40,6 +40,7 @@ import dev.lain.claudejb.protocol.parseUsageReport import dev.lain.claudejb.protocol.str import dev.lain.claudejb.settings.ClaudeSettings import dev.lain.claudejb.settings.Provider +import dev.lain.claudejb.settings.SecretStore import dev.lain.claudejb.ui.ClaudeSettingsConfigurable import dev.lain.claudejb.ui.ReviewPrompt import kotlinx.serialization.json.JsonArray @@ -369,6 +370,19 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : private val quotaPollTimer = javax.swing.Timer(QUOTA_POLL_MS) { pollQuota() }.apply { isRepeats = true } + /** Guards [pollQuota] against overlapping round-trips; see the comment there. EDT-confined. */ + private var quotaPollInFlight = false + + /** + * True once the `initialize` handshake has answered — i.e. the binary is up AND talking, with commands, + * models and the account in hand. The GUI treats THIS, not process liveness, as "loaded": a spawned + * process that has not answered yet would hand the user a chat whose menus and dashboard are empty and + * fill in afterwards. Reset on every launch and teardown. + */ + @Volatile + var initialized: Boolean = false + private set + /** * Fire one session-cost + context-usage poll; results are cached and pushed to panels via [fireState]. * No-op while the process is not running (the control requests would deliver null and clobber the cached @@ -377,22 +391,34 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : */ private fun pollQuota() { if (!isRunning()) return - requestSessionCost { cost -> - if (cost != null) { - lastSessionCost = cost + // Never let polls overlap. The control channel is SHARED with `can_use_tool` and the tool-result + // traffic, so at a one-second cadence a binary busy streaming answers slower than we ask, the + // requests pile up, and everything queued behind them — tool cards finishing, permissions — waits + // on two numbers. One poll in flight at a time; a slow answer skips a tick instead of stacking. + if (quotaPollInFlight) return + quotaPollInFlight = true + var pending = 2 + // ONE state push per poll, not one per answer: a full push re-serializes meta + state + dashboard, + // and doing it twice a second competed with the streaming transcript for no new information. + val settle = { + if (--pending == 0) { + quotaPollInFlight = false fireState() } } + requestSessionCost { cost -> + if (cost != null) lastSessionCost = cost + settle() + } requestContextUsage { cu -> - if (cu != null) { - lastContextUsage = cu - fireState() - } + if (cu != null) lastContextUsage = cu + settle() } // The timer exists to track a turn AS IT RUNS, nothing else. Context and cost cannot move while the - // session sits idle, so a poll-per-minute forever was a round-trip through the binary — per tab — for - // two numbers that provably had not changed. It now switches itself off at the end of a turn; the - // turn-start, turn-end and process-ready paths each poll directly, so nothing waits on a clock. + // session sits idle, so polling forever was a round-trip through the binary for two numbers that + // provably had not changed — and retiring at turn end is also what makes the 1-second cadence + // affordable at all. The turn-start, turn-end and process-ready paths each poll directly, so nothing + // waits on a clock. if (!turnActive) edt { quotaPollTimer.stop() } } @@ -485,9 +511,17 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : val settings = ClaudeSettings.getInstance(project) val binary = resolveBinary(settings) ?: return false if (!passesLaunchGates(settings)) return false + if (!hasCredential(settings)) { + // Sign-in comes BEFORE the loading screen, not after it. Verifying auth needs no session, and + // launching one we know is unauthenticated only buys a spawned process, a spinner, and a turn + // that fails later for a reason the user already knew at click time. + onLoginNeeded() + return false + } val workDir = project.basePath?.let(::File) ?: File(System.getProperty("user.home")) ready = false + initialized = false starting = true reconciler.onMessageBoundary() // Tell the GUI we are booting BEFORE handing off to the pooled thread, so the loading screen is up for @@ -514,12 +548,273 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : return true } + /** + * True when the last launch attempt found no `claude` binary anywhere. Drives the boot screen's + * "Claude Code was not found" card (install buttons + manual path) instead of a spinner that clears + * into an empty tab with only a toast to explain itself. Cleared the moment a resolve succeeds. + */ + @Volatile + var binaryMissing: Boolean = false + private set + + /** + * True when the binary looks unauthenticated — proactively (the `auth status` probe on process ready) + * or reactively ([LoginDetection] on a failed turn / an auth-status error). Drives the sign-in card + * (subscription OAuth or API key); cleared by the next clean turn, a positive probe, and + * [dismissLoginCard]. The card and [LoginCoordinator]'s notification coexist on purpose: the + * notification reaches a user whose chat tab is hidden, the card reaches the one staring at it. + */ + @Volatile + var needsLogin: Boolean = false + private set + + private fun onLoginNeeded() { + needsLogin = true + edt { fireState() } + login.maybePrompt() + } + + /** + * ONCE per session, on the first boot check: if the machine already has a plaintext + * `~/.claude/.credentials.json` — a login the user made in their terminal, or an orphan from a hard IDE + * kill — take it into the safe and delete it. That login then counts as ours and the tab starts signed + * in instead of asking again. + * + * Once, and only here. Doing it on every poll deleted the file every few seconds, and `auth login` + * finishes by writing exactly that file: the browser leg lost its credential the instant it earned it, + * and the code-paste fallback became the only route that ever completed. A sign-in in flight writes its + * own credential into the safe when it succeeds ([LoginCoordinator]) — the vault does not need to go + * looking for it. + */ + private fun absorbExistingLoginOnce() { + if (startupHarvestDone) return + startupHarvestDone = true + // ORDER IS THE WHOLE POINT, and it is the same order the card's sign-in follows + // ([LoginCoordinator.completeSignIn]): ask WHO first, take the credential second. Reversed, the + // question can no longer be answered by anybody. + captureAccountIdentityOnce() + dev.lain.claudejb.process.CredentialsVault.harvest() + } + + /** + * Captures `claude auth status` — the whole JSON, into the IDE safe — while the binary's own credentials + * file still exists, because the very next line takes that file away. + * + * This is why the dashboard's Email and Organization rows were empty. `auth status` names the account + * (`email`, `orgId`, `orgName`) only when it authenticates from its OWN store. Handed our credential + * through the environment it answers `authMethod: oauth_token` and no identity at all, and `system/init` + * carries the same anonymous account object — which is exactly why Plan and Provider filled in while + * those two rows stayed blank. Harvest the credential first and there is nothing left to ask: the file was + * the only thing that could answer. + * + * A login made in the user's own terminal is the case this covers; a sign-in through the card is already + * in the right order. [dev.lain.claudejb.process.AuthCli.status] does the filing, and only for a reply + * that names the account, so this asks at most once per sign-in — with an answer banked there is nothing + * to ask. Blocking (it spawns the binary); every caller of this is pooled-thread only. + */ + private fun captureAccountIdentityOnce() { + // Never from a test JVM. [dev.lain.claudejb.process.CredentialsVault.credentialsFile] resolves the + // DEVELOPER's real home there, so this would probe on the strength of their own login and file the + // answer in a throwaway safe — the same reason the vault refuses to touch a real home under test. It + // also spawns the stand-in binary, which has no `auth status` to answer with. + if (ApplicationManager.getApplication()?.isUnitTestMode != false) return + if (dev.lain.claudejb.process.AuthCli.stored()?.email != null) return + if (!dev.lain.claudejb.process.CredentialsVault.credentialsFile().isFile) return + val settings = ClaudeSettings.getInstance(project) + val binary = ClaudeBinaryLocator.locate(settings.claudePath) ?: return + // The RAW settings env: overlaying our own credential is precisely what makes the answer anonymous. + dev.lain.claudejb.process.AuthCli.status(binary, settings.resolveEnv()) + } + + @Volatile + private var startupHarvestDone = false + + /** + * Whether the live process was launched with `--resume`. Read in [onTerminated]: a resumed launch that + * dies before the handshake is a conversation the binary cannot find, which is a recoverable condition + * (drop the id, open a fresh one) and not the generic "it exited" failure. + */ + @Volatile + private var resumedLaunch = false + + /** + * Whether this session has an identity to run as — checked BEFORE spawning anything, since that is a + * question about what we hold, not about what the binary can do. + * + * The identity is exclusively: the vaulted subscription login, an API key in its provider slot, or a + * credential the user wrote by hand into the Settings environment. Nothing held → logged out by + * definition, and no process is started to re-ask a question we have already answered. + * + * Deliberately does NOT harvest — see [absorbExistingLoginOnce]. + */ + private fun hasCredential(settings: ClaudeSettings): Boolean { + if (dev.lain.claudejb.process.CredentialsVault.hasUsableToken()) return true + if (SecretStore.get(SecretStore.OAUTH_TOKEN) != null) return true + if (settings.getProviderApiKey(settings.provider).isNotBlank()) return true + val explicit = settings.resolveEnv() + if (SecretStore.API_KEY in explicit || SecretStore.OAUTH_TOKEN in explicit) return true + // An explicit Log out outranks the binary's own login: otherwise clearing our safe changes nothing + // the user can see, because the binary still holds one and the session starts straight back up. + if (settings.state.signedOut) return false + return binaryHoldsOwnLogin(settings) + } + + /** + * Last resort: does the BINARY hold a login of its own? + * + * This is what makes the plugin work off Linux. The vault only ever engages when there is a plaintext + * `~/.claude/.credentials.json` to take custody of — which is the Linux situation. On macOS the binary + * keeps its credentials in the **Keychain** and writes no such file, so a vault-only view of the world + * concludes "signed out" no matter how many times the user signs in: the login card would reappear + * immediately after every successful sign-in, forever. Windows behaves the same wherever the binary uses + * a store rather than a file. + * + * So when we hold nothing, we ask instead of assuming. A binary with its own valid login is simply left + * to use it: no vault, no config dir, no environment token — and, because it authenticates from its own + * store, the dashboard gets the complete account and plan picture there too. + * + * Throttled hard ([OWN_LOGIN_TTL_MS]): this spawns a process, and the caller polls every few seconds. + */ + private fun binaryHoldsOwnLogin(settings: ClaudeSettings): Boolean { + val now = System.currentTimeMillis() + ownLoginCheckedAt.takeIf { now - it < OWN_LOGIN_TTL_MS }?.let { return binaryOwnLogin } + val binary = ClaudeBinaryLocator.locate(settings.claudePath) ?: return false + // The RAW settings env, deliberately: overlaying our own credentials would be asking the binary + // whether IT is signed in while handing it ours. + val status = dev.lain.claudejb.process.AuthCli.status(binary, settings.resolveEnv()) + binaryOwnLogin = status?.loggedIn == true + ownLoginCheckedAt = now + return binaryOwnLogin + } + + @Volatile private var binaryOwnLogin = false + + @Volatile private var ownLoginCheckedAt = 0L + + /** + * Re-evaluates which screen this tab should be showing, from scratch. Called periodically while no + * session is running — BLOCKING (it stats the filesystem and reads the PasswordSafe), so pooled thread + * only; the state changes hop to the EDT themselves. + * + * Detection used to happen exactly once, inside [start]. So a tab that opened before Claude Code was + * installed kept its stale answer forever: installing the binary, or signing in from somewhere else, + * changed nothing until the tab was closed and reopened. The three states are a function of the world + * (binary present? credential held?) and the world changes underneath us, so they are re-derived rather + * than remembered. + */ + fun refreshBootState() { + if (starting) return + // A sign-in is mid-flight: keep out. This poll harvests the credentials file, and `auth login` + // writes exactly that file to finish — taking it away mid-flow breaks the browser leg. + if (login.inProgress) return + absorbExistingLoginOnce() + val settings = ClaudeSettings.getInstance(project) + val binary = ClaudeBinaryLocator.locate(settings.claudePath) + if ((binary == null) != binaryMissing) { + binaryMissing = binary == null + edt { fireState() } + } + if (binary == null) { + // The binary went away under a live session — uninstalled, or a path that no longer resolves. + // Stop it before showing the install screen, or the user reads "not installed" while a process + // from the vanished copy is still answering. + edt { if (isRunning()) stop() } + return + } + // Persist a freshly-installed binary's path here too, not only in resolveBinary: the install card's + // "it appeared" path went through a start() that could return before ever writing it down. + if (settings.claudePath != binary.absolutePath) settings.state.claudePath = binary.absolutePath + val credentialed = hasCredential(settings) + edt { + if (starting) return@edt + when { + // Signed out: an expired token, a Log out, a credential cleared elsewhere. STOP FIRST — a + // live process still holds the old identity, so leaving it up means the tab says "signed + // out" while the next turn happily works. + !credentialed -> { + if (isRunning()) stop() + // Already asked; do not re-fire, or the notification would repeat every few seconds. + if (!needsLogin) onLoginNeeded() + } + + !isRunning() -> start() + } + } + } + + /** The card's "I'm already signed in": hide it until the next auth failure says otherwise. */ + fun dismissLoginCard() { + needsLogin = false + edt { fireState() } + } + + /** + * Proactive auth check, off-EDT: `claude auth status --json` with the full launch env — so the answer + * covers every identity the session can actually run on, in the order the binary itself resolves them: + * an env credential (the PasswordSafe overlay / explicit Settings vars) first, its own credential store + * (the full-consent `auth login`, shared with the terminal CLI) second. Not logged in by ANY of those → + * the sign-in card is the first thing the tab shows, before a turn can fail on it. + * + * A probe that cannot run or parse yields a SYNTHETIC logged-out state rather than silence: the account + * card's button must always exist and say something ("Sign in" that leads to an idempotent login beats + * a button that omits itself and cannot be found). + */ + fun probeAuthStatus() { + val settings = ClaudeSettings.getInstance(project) + val binary = ClaudeBinaryLocator.locate(settings.claudePath) ?: return + ApplicationManager.getApplication().executeOnPooledThread { + val onOurEnv = dev.lain.claudejb.process.AuthCli.status(binary, effectiveLaunchEnv()) + ?: dev.lain.claudejb.process.AuthCli.AuthState(loggedIn = false) + // WHO the account is takes a second question, and this is why the dashboard's Email and + // Organization rows were empty: asked with our credential in its environment the binary reports + // `authMethod: oauth_token` and no identity at all. Asked with the RAW settings env it answers + // from its own store as `claude.ai` — email, orgId, orgName, plan — which AuthCli.status files in + // the safe. `loggedIn` stays the first answer's: that one describes the identity this session + // actually runs on. Skipped entirely once the first answer already named the account. + // Only worth a second question when somebody IS signed in: an anonymous logged-OUT answer has no + // identity to go looking for, and asking anyway spawns a second process per probe for nothing. + val status = if (!onOurEnv.loggedIn || onOurEnv.email != null || onOurEnv.orgName != null) { + onOurEnv + } else { + val identity = dev.lain.claudejb.process.AuthCli.status(binary, settings.resolveEnv()) + ?.takeIf { it.email != null || it.orgName != null } + ?: dev.lain.claudejb.process.AuthCli.stored() + onOurEnv.copy( + email = identity?.email, + orgId = identity?.orgId, + orgName = identity?.orgName, + apiProvider = onOurEnv.apiProvider ?: identity?.apiProvider, + subscriptionType = onOurEnv.subscriptionType ?: identity?.subscriptionType, + ) + } + authCliStatus = status + if (!status.loggedIn) { + onLoginNeeded() + } else if (needsLogin) { + needsLogin = false + edt { fireState() } + } else { + edt { fireState() } // account card enrichment (email/plan) still wants a push + } + } + } + + /** Last `auth status` probe result — feeds the dashboard's account card (email, plan, Sign in/Log out). */ + @Volatile + var authCliStatus: dev.lain.claudejb.process.AuthCli.AuthState? = null + private set + /** Locates the binary and persists the resolved path; null (after notifying) when there is none. */ private fun resolveBinary(settings: ClaudeSettings): File? { val binary = ClaudeBinaryLocator.locate(settings.claudePath) ?: run { + binaryMissing = true + // The state push is what flips the boot screen into the not-found card; without it the flag + // sits unread until some unrelated event happens to re-push. + fireState() notifyMissingBinary() return null } + binaryMissing = false // Persist the auto-detected path so later launches are stable and the user can see/edit it // (also refreshes a stale saved path that fell back to auto-detection). if (settings.claudePath != binary.absolutePath) settings.state.claudePath = binary.absolutePath @@ -548,6 +843,32 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : return true } + /** + * The env the `claude` process is launched with: the settings-resolved base plus the credentials held + * in the IDE's PasswordSafe ([SecretStore]) — overlaid ONLY where the explicit env doesn't already carry + * the name, so a hand-written Settings value keeps winning. Credentials travel exclusively through the + * environment (never argv — /proc//cmdline is world-readable, environ is 0400), and through here + * for every consumer, so the auth probe sees exactly what the session process will. + */ + internal fun effectiveLaunchEnv(base: Map? = null): Map { + val env = base ?: ClaudeSettings.getInstance(project).resolveEnv() + val settings = ClaudeSettings.getInstance(project) + // The Anthropic API key, from its own provider slot. Only when the selected provider IS Anthropic: + // under a third-party provider `resolveEnv` has already put THAT provider's key in, and overwriting + // it here would send an Anthropic credential to a non-Anthropic endpoint. + val apiKey = settings.anthropicApiKey + .takeIf { it.isNotBlank() && settings.provider == Provider.ANTHROPIC && SecretStore.API_KEY !in env } + val withSecrets = env + + SecretStore.envOverlay(env.keys) + + (apiKey?.let { mapOf(SecretStore.API_KEY to it) } ?: emptyMap()) + // The binary runs against its OWN `~/.claude`, untouched. The vaulted subscription login rides here + // instead — the WHOLE credential, field by field (token, refresh token, scopes, subscription, rate + // tier, account), which is what lets the session run with NOTHING in ~/.claude/.credentials.json and + // still report the plan limits. See CredentialsVault.envOverlay: the missing piece was the SCOPES, + // not a config directory. Last, and keyed on the merged env, so an explicit API key or token wins. + return withSecrets + dev.lain.claudejb.process.CredentialsVault.envOverlay(withSecrets.keys) + } + /** * Spawns the process off the EDT and, once it is up, hands back to the EDT to flip [ready]. * @@ -556,10 +877,16 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : * behind nor mark a session that no longer owns the generation as ready. */ private fun launch(launchGen: Int, settings: ClaudeSettings, binary: File, workDir: File, resume: Boolean) { - val env = cachedEnv ?: settings.resolveEnv().also { cachedEnv = it } + // No harvest here: absorbing an existing plaintext login is a ONCE-per-session act + // ([absorbExistingLoginOnce]), and a sign-in files its own credential when it succeeds. + // + // The credential reaches the binary through the environment, WHOLE (CredentialsVault.envOverlay), and + // the binary keeps its own `~/.claude`. Nothing is relocated, symlinked or deleted. + val env = effectiveLaunchEnv(cachedEnv ?: settings.resolveEnv().also { cachedEnv = it }) // A stop()/dispose()/newer start() may have raced in during the (slow) env resolution. If so, this // launch is stale — don't spawn an orphan process nothing will ever tear down. if (launchGen != generation) return + resumedLaunch = resume val opts = launchOptions() val proc = ClaudeProcess( binary = binary, @@ -594,6 +921,11 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : edt { ready = true transcript.add(Speaker.SYSTEM, "Claude Code ready.") + // Auth check HERE, at launch-time readiness — NOT (only) on the Init event, because per the + // note above `system/init` doesn't arrive until after the first user turn. Hooked there alone, + // the sign-in card waited for the user to type a prompt before appearing, which is exactly + // backwards: with no login the card must be the first thing the tab shows. + probeAuthStatus() fireState() // Fill the context and cost meters NOW rather than on the poll timer's first tick. // @@ -625,6 +957,16 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : agents = info.agents availableOutputStyles = info.availableOutputStyles account = info.account + log.debug( + "CC-TRACE initialize reply: account(email=${info.account.email.isNotBlank()}," + + " org=${info.account.organization.isNotBlank()}, plan='${info.account.subscriptionType}'," + + " provider='${info.account.apiProvider}') models=${info.models.size}" + + " commands=${info.commands.size} agents=${info.agents.size}", + ) + // The handshake answered: the process is not merely spawned, it is DELIVERING. This is what + // takes the loading screen down, so the chat's first frame is drawn with the command list, + // the model catalog and the account already in hand rather than filling in behind it. + initialized = true if (info.outputStyle.isNotBlank()) outputStyle = info.outputStyle // Graceful fallback: the pin ([DEFAULT_MODEL]) was chosen before the catalog was known. If this // binary doesn't actually offer it, re-resolve against the real catalog and push the correction @@ -680,6 +1022,7 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : turnActive = false interrupting = false ready = false + initialized = false starting = false // any in-flight launch is now stale (generation bumped above); let a restart proceed // Reset per-turn live state so a stale figure/chip doesn't linger into a resumed session (restart path). liveThinkingTokens = 0 @@ -1635,10 +1978,11 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } transcript.add(Speaker.ERROR, message) // If the failure reads like a login/auth problem, offer to open an interactive terminal — - // /login can't run inside the TTY-less stream-json session. - if (LoginDetection.needsLogin(message)) login.maybePrompt() + // /login can't run inside the TTY-less stream-json session — and raise the login card. + if (LoginDetection.needsLogin(message)) onLoginNeeded() } else { // A clean turn means we're authenticated; allow a future auth failure to prompt again. + needsLogin = false login.onCleanResult() // Count it toward the one-and-only Marketplace review ask. Only successful turns count, so // nobody is ever asked to rate a session that was failing on them. See [ReviewPrompt]. @@ -1763,6 +2107,10 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : private fun onRateLimit(event: ClaudeEvent.RateLimit) { val incoming = event.info + log.debug( + "CC-TRACE rate_limit_event: window=${incoming.rateLimitType} status=${incoming.status}" + + " utilization=${incoming.utilization} -> pct=${incoming.utilizationPercent()}", + ) val window = incoming.rateLimitType // The binary often emits a rate_limit_event without `utilization` (it's optional and only present when // the API returns it). Don't lose a previously-known utilization just because a later event omitted @@ -1784,7 +2132,7 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : event.info.error?.takeIf { it.isNotBlank() }?.let { edt { transcript.add(Speaker.ERROR, "Authentication error: $it") - if (LoginDetection.needsLogin(it)) login.maybePrompt() + if (LoginDetection.needsLogin(it)) onLoginNeeded() } } edt { fireState() } @@ -1928,6 +2276,10 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : // callback belongs to the old process and must NOT tear down the freshly-started session (which would // null `ready`, failAll the new initialize, and print "Session ended"). The current generation wins. if (gen != generation) return + // Read BEFORE the teardown below clears it: a resume that failed is one that died without ever + // answering the handshake. A process that was up and working and then crashed is an ordinary failure, + // and its session id is still good. + val staleResume = resumedLaunch && !initialized // Flush any buffered streaming deltas (reader thread) before tearing down so trailing text isn't dropped. flushDeltas() // The process is gone: release any in-flight control callbacks so their dialogs don't hang. @@ -1936,11 +2288,29 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : turnActive = false interrupting = false ready = false + initialized = false liveThinkingTokens = 0 promptSuggestion = null cards.clear() taskTracker.clear() hookNarrator.clear() + if (exitCode != 0 && staleResume) { + // `--resume ` on a conversation the binary doesn't have: it prints "No conversation found + // with session ID: …" and exits 1, immediately, every time (verified against 2.1.223). The boot + // watcher then relaunches every few seconds, so this is not one failure but an endless loop of + // them — a tab stuck on "Loading Claude Code…" behind a stack of identical error toasts. + // + // The id is simply stale (a session that never got a turn written, a transcript deleted + // elsewhere), so it is DROPPED and the tab continues as a new conversation. Restoring history + // is best-effort; refusing to open a chat over it is not a trade worth making. + log.info("resume of session $sessionId failed (exit $exitCode) — continuing as a new conversation") + sessionId = null + resumedLaunch = false + systemNotice("That conversation is no longer available — started a new one.") + fireState() + start(resume = false) + return@edt + } if (exitCode != 0) { transcript.add(Speaker.ERROR, "Claude Code exited (code $exitCode).") // The user may not have this tab focused; also raise a notification so the failure isn't missed. @@ -2041,7 +2411,14 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } /** Runs the OAuth sign-in. Delegates to [login]; public so the composer can route a typed `/login` here. */ - fun startLogin() = login.start() + fun startLogin(mode: LoginCoordinator.Mode = LoginCoordinator.Mode.SUBSCRIPTION) = login.start(mode) + + // The sign-in card's plumbing, one delegate each — the card lives in the panel, the flow in the + // coordinator, and the session stays the thin orchestrator between them. + fun attachLoginUi(ui: LoginCoordinator.LoginUi) = login.attachUi(ui) + fun detachLoginUi(ui: LoginCoordinator.LoginUi) = login.detachUi(ui) + fun submitLoginCode(code: String) = login.submitCode(code) + fun cancelLogin() = login.cancelLogin() /** * EDT-only. Returns true if the session may launch: either there's no risky exec config (sourceScript / stdio @@ -2121,8 +2498,17 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : const val CONTROL_TIMEOUT_SECONDS = 30L /** Interval (ms) of the session-scoped quota poll (get_session_cost + get_context_usage), shared by all - * ChatPanels observing this session — one timer per session, not one per tab. */ - const val QUOTA_POLL_MS = 60_000 + * ChatPanels observing this session — one timer per session, not one per tab. + * + * One second, and the budget holds because of two multipliers already in place: the timer only runs + * WHILE A TURN IS ACTIVE (it retires at turn end — idle sessions poll zero times), and both requests + * are local IPC to the `claude` process, which answers from its own counters without a network hop. + * At 60s the context meter and cost sat visibly frozen through a whole turn and only told the truth + * after it ended, which reads as a broken meter exactly while the user is watching it. */ + const val QUOTA_POLL_MS = 1_000 + + /** How long a "the binary has its own login" answer is trusted. It costs a process spawn to get. */ + private const val OWN_LOGIN_TTL_MS = 30_000L /** * Default model on a fresh install: the concrete Opus tier is **pinned** (not the binary's floating diff --git a/src/main/kotlin/dev/lain/claudejb/session/LoginCoordinator.kt b/src/main/kotlin/dev/lain/claudejb/session/LoginCoordinator.kt index 64cbf2f8..a5e5f072 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/LoginCoordinator.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/LoginCoordinator.kt @@ -1,12 +1,13 @@ package dev.lain.claudejb.session -import com.intellij.ide.BrowserUtil import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages +import dev.lain.claudejb.process.AuthCli import dev.lain.claudejb.process.ClaudeBinaryLocator import dev.lain.claudejb.process.ClaudeLoginFlow import dev.lain.claudejb.process.TerminalLauncher @@ -47,6 +48,33 @@ class LoginCoordinator( private val log = thisLogger() + /** + * The sign-in card, when a chat panel is attached. This seam is what makes the flow NATIVE: with a UI + * present the whole OAuth dance runs inside the plugin (card → browser → code back into the card) and + * neither a terminal tab nor a modal dialog ever appears. Registered by JcefChatPanel in `init`, + * detached in `dispose`; with none attached the legacy paths below still work. + */ + interface LoginUi { + /** The OAuth authorize URL: show it on the card (the browser is opened separately). */ + fun onAuthUrl(url: String) + + /** The binary now waits for the authorization code — the card swaps to its code input. */ + fun onCodeRequested() + + /** The flow ended. On success the session restart follows; on failure [message] goes on the card. */ + fun onLoginResult(success: Boolean, message: String) + } + + @Volatile private var ui: LoginUi? = null + + fun attachUi(loginUi: LoginUi) { + ui = loginUi + } + + fun detachUi(loginUi: LoginUi) { + if (ui === loginUi) ui = null + } + /** * Set once we've offered the sign-in for the current auth-failure streak, so a retry storm doesn't fire one * notification per failed turn. Cleared by [onCleanResult] on the next non-error result — which is also what @@ -59,6 +87,61 @@ class LoginCoordinator( @Volatile private var authUrl: String? = null + /** + * True while a sign-in is actually in flight. + * + * Load-bearing for [ClaudeSession.refreshBootState], which harvests the credentials file every few + * seconds: `claude auth login` WRITES that file as it completes, so harvesting mid-flow deletes the + * credential out from under the binary and the browser leg silently fails — leaving the code-paste + * fallback as the only route that ever worked. Nothing touches that file while this is true. + */ + val inProgress: Boolean get() = signingIn + + /** + * Deliberately NOT derived from `flow != null`. That was the bug: `flow` is assigned only AFTER the PTY + * has been started, and cleared at the TOP of the result handler, leaving two windows in which a sign-in + * was running while this read false — long enough for the watcher to harvest (and now stop the session) + * exactly as the binary was writing the credential it had just been granted. This is raised before + * anything spawns and lowered only when the flow is completely done with. + */ + @Volatile private var signingIn = false + + /** The sign-in the user asked for; read when the flow is actually spawned. */ + @Volatile private var loginMode = Mode.SUBSCRIPTION + + /** + * Feeds the card-entered authorization code to the running flow, with a WATCHDOG: if the flow gives no + * verdict within [VERIFY_TIMEOUT_MS], it is cancelled and the card told so. A submit that hangs must + * end in words, never in a spinner the user has to give up on — that exact dead end was observed live + * (the raw-TTY Enter defect) and the escape has to exist independently of that fix being right. + */ + fun submitCode(code: String) { + val current = flow ?: return + current.submitCode(code) + verifyTimer?.stop() + verifyTimer = javax.swing.Timer(VERIFY_TIMEOUT_MS) { + if (flow === current) { + cancelLogin() + ui?.onLoginResult(false, "No answer after submitting the code. Try again — or use the API-key route.") + } + }.apply { + isRepeats = false + start() + } + } + + private var verifyTimer: javax.swing.Timer? = null + + /** The card's Cancel: kill the in-flight flow, if any. */ + fun cancelLogin() { + verifyTimer?.stop() + verifyTimer = null + flow?.cancel() + flow = null + authUrl = null + signingIn = false + } + /** A turn ended cleanly: the auth-failure streak is over, so the next failure may prompt again. */ fun onCleanResult() { prompted = false @@ -87,8 +170,23 @@ class LoginCoordinator( .notify(project) } - /** Runs the OAuth login through the three paths documented on this class. Also the target of a typed `/login`. */ - fun start() { + /** + * Which sign-in to run. Both are `claude auth login`; the flag decides what the OAuth grant is for. + * + * [CONSOLE] is the one organisations need: it signs in against Anthropic Console with API-usage + * billing rather than a personal Claude subscription, and the consent it requests includes + * `org:create_api_key` — so a corporate account is provisioned by signing in, with no key pasted by + * hand and none to distribute. [SSO] forces the SSO leg for org accounts that require it. + */ + enum class Mode(val args: List) { + SUBSCRIPTION(listOf("auth", "login")), + CONSOLE(listOf("auth", "login", "--console")), + SSO(listOf("auth", "login", "--sso")), + } + + /** Runs the OAuth login through the three paths documented on this class; also a typed `/login`. */ + fun start(mode: Mode = Mode.SUBSCRIPTION) { + loginMode = mode val settings = ClaudeSettings.getInstance(project) // /login is the Anthropic OAuth flow — only meaningful for the official Anthropic provider. For a // third-party provider, auth is its own API key (configured in Settings), not an OAuth login. @@ -103,20 +201,90 @@ class LoginCoordinator( notifyMissingBinary() return } + // PRIMARY: the in-card native flow, whenever a card exists to drive it. The terminal is the fallback + // (PTY spawn failed), the "run it yourself" notice the last resort — the exact inverse of the pre-5 + // ordering, which dropped the user into a terminal tab as the happy path. + val cardUi = ui + if (cardUi != null && startCardFlow(binary, cardUi)) return edt { if (openTerminal(binary)) return@edt - log.info("IDE terminal unavailable for /login — falling back to the native PTY flow") + log.info("IDE terminal unavailable for /login — falling back to the dialog-driven PTY flow") if (startNativePtyFlow(binary)) { notifyInfo("Signing in… your browser should open. Approve access there to finish.") return@edt } notifyError( "Couldn't start the sign-in flow. Run this in a terminal, then restart the chat:\n" + - TerminalLauncher.loginCommand(binary.absolutePath), + TerminalLauncher.loginCommand(binary.absolutePath, loginMode.args), ) } } + /** + * The native, card-driven subscription flow: `claude auth login` under a PTY, every step surfaced on + * the sign-in card and nothing else on screen. + * + * `auth login`, NOT `setup-token`, and the difference is the OAuth CONSENT: setup-token mints a + * reduced-scope token, and the scopes it drops (file upload among them) are ones Claude Code actually + * exercises — a pasted attachment travels through "Upload files on your behalf". So the full login it + * is, with its cost stated rather than hidden: the credentials land in the binary's own store, SHARED + * with the terminal CLI (OS keychain on macOS, DPAPI on Windows, a 0600 file on Linux) — subscription + * identity is no longer separable from the CLI's, and Log out signs the terminal out too. The + * PasswordSafe remains the home of the API-key route, which stays fully plugin-owned. + * + * Returns false when the PTY could not spawn, so [start] falls back to the terminal. + */ + private fun startCardFlow(binary: File, cardUi: LoginUi): Boolean { + // pty4j REPLACES the child env wholesale — merge the base in or the binary loses PATH/HOME. + val env = System.getenv() + ClaudeSettings.getInstance(project).resolveEnv() + // BEFORE the spawn: from here until the flow is fully settled, nothing else may touch the + // credentials file — `auth login` finishes by writing it, and taking it away mid-flow is what broke + // the browser leg and left code-paste as the only route that worked. + signingIn = true + val ptyFlow = ClaudeLoginFlow(binary.absolutePath, project.basePath, env, args = loginMode.args) + val started = ptyFlow.start(object : ClaudeLoginFlow.Listener { + override fun onAuthUrl(url: String) { + authUrl = url + // NO BrowserUtil.browse: the binary opens the browser itself, and THAT is the tab that + // completes the sign-in. Opening it again from here just added a second tab on top. + // + // Removing this once looked like it broke the browser leg, which is why it came back for a + // build. It did not: what broke it was the boot watcher harvesting (and deleting) + // ~/.claude/.credentials.json mid-flow — `auth login` finishes by writing exactly that file. + // With that race closed (LoginCoordinator.signingIn) the binary's own tab works, so ours is + // pure duplication. The card's "Open your browser" button remains for the case where the + // binary's attempt genuinely fails. + edt { cardUi.onAuthUrl(url) } + } + + override fun onCodeRequested() = edt { cardUi.onCodeRequested() } + + override fun onToken(token: String) { + // `auth login` normally prints none; if a flow variant ever does, the safe is the one + // place it may go. Never logged, never shown, never echoed back. + dev.lain.claudejb.settings.SecretStore.set(dev.lain.claudejb.settings.SecretStore.OAUTH_TOKEN, token) + } + + override fun onResult(success: Boolean, message: String) = edt { + verifyTimer?.stop() + verifyTimer = null + flow = null + authUrl = null + if (success) { + completeSignIn(binary) { ok, text -> + cardUi.onLoginResult(ok, if (ok) message else text) + if (ok) notifyInfo(text) else notifyError(text) + } + } else { + signingIn = false + cardUi.onLoginResult(false, message) + } + } + }) + if (started) flow = ptyFlow else signingIn = false // a PTY that never spawned holds no guard + return started + } + /** * Opens an IDE terminal running `claude auth login`. Preferred over the PTY flow because the binary drives its * whole interactive TUI visibly and captures the OAuth callback automatically (usually nothing to paste). @@ -127,7 +295,7 @@ class LoginCoordinator( private fun openTerminal(binary: File): Boolean { val opened = TerminalLauncher.openAndRunCommand( project, - TerminalLauncher.loginArgv(binary.absolutePath), + listOf(binary.absolutePath) + loginMode.args, "claude login", ) if (opened) { @@ -157,11 +325,13 @@ class LoginCoordinator( // pty4j REPLACES the child env wholesale (unlike ClaudeProcess, which inherits the parent's and layers // extras on top), so the base environment has to be merged in here or the binary loses PATH/HOME entirely. val env = System.getenv() + ClaudeSettings.getInstance(project).resolveEnv() - val ptyFlow = ClaudeLoginFlow(binary.absolutePath, project.basePath, env) + signingIn = true // same guard as the card flow, raised before the spawn + val ptyFlow = ClaudeLoginFlow(binary.absolutePath, project.basePath, env, args = loginMode.args) val started = ptyFlow.start(object : ClaudeLoginFlow.Listener { override fun onAuthUrl(url: String) { + // Same as the card flow: the binary's own tab is the one that completes the sign-in, so we + // do not open a second. The URL is kept as the hint shown in the code dialog. authUrl = url - edt { BrowserUtil.browse(url) } } override fun onCodeRequested() = edt { promptForCode(ptyFlow) } @@ -170,17 +340,82 @@ class LoginCoordinator( flow = null authUrl = null if (success) { - notifyInfo(message) - restartSession() // pick up the new credentials + completeSignIn(binary) { ok, text -> if (ok) notifyInfo(text) else notifyError(text) } } else { + signingIn = false notifyError(message) } } }) - if (started) flow = ptyFlow + if (started) flow = ptyFlow else signingIn = false return started } + /** + * Everything that happens **after** `claude auth login` exits 0, in the order it has to happen in: + * + * 1. **verify** — `claude auth status` (also exit-0/`loggedIn`), because "the login command succeeded" and + * "this machine now has a login" are two different claims and only the second one is worth acting on. + * A rc-0 login with no identity behind it would otherwise be banked as one, and the failure would only + * surface later as a session that dies on its first turn; + * 2. **take custody** — the credential the binary just wrote goes into the IDE's encrypted safe and is + * deleted from disk ([takeCustodyOfCredential]), with the account banked while the config is freshest; + * 3. **launch** — the session starts normally, on what we now hold. + * + * Steps 1–2 spawn a process and touch files, so they run off the EDT; [done] is invoked back on the EDT + * with the outcome and the line to show. The [signingIn] guard is only lowered once all of it is over — + * the boot watcher must not go looking for that credentials file while we are still moving it. + */ + private fun completeSignIn(binary: File, done: (Boolean, String) -> Unit) { + val env = ClaudeSettings.getInstance(project).resolveEnv() + ApplicationManager.getApplication().executeOnPooledThread { + val verified = AuthCli.status(binary, env)?.loggedIn == true + val vaulted = if (verified) { + dev.lain.claudejb.process.AccountProfile.capture() + ClaudeSettings.getInstance(project).state.signedOut = false + takeCustodyOfCredential() + } else { + log.warn("'auth login' exited 0 but 'auth status' reports no login — not banking a credential") + false + } + edt { + signingIn = false + done( + verified, + when { + !verified -> "Signed in, but Claude Code still reports no account. Please try again." + vaulted -> "Signed in. Your credentials were moved into the IDE's password safe." + else -> "Signed in to Claude." + }, + ) + if (verified) restartSession() // launch on what we now hold + } + } + } + + /** + * Moves whatever credential the completed sign-in left on disk into the IDE's encrypted storage, and + * deletes the on-disk copy. One place for both sign-in modes, because they leave DIFFERENT things behind: + * + * - **subscription** → OAuth tokens in `~/.claude/.credentials.json` → [CredentialsVault]; + * - **Console** → a freshly minted API key as `primaryApiKey` in `~/.claude.json` → [ConsoleApiKey], + * filed in the Anthropic provider slot (the same one Settings ▸ Provider and the card's API-key field + * use, so there is one credential and three doors onto it) and approved for non-interactive use, since + * an unapproved key is refused under `--print` — see [ApiKeyApproval]. + * + * Both are attempted regardless of the requested mode: `--sso` can land on either, and a mode that left + * nothing behind simply finds nothing. + * + * @return true when something was taken into the safe. + */ + private fun takeCustodyOfCredential(): Boolean { + val vaulted = dev.lain.claudejb.process.CredentialsVault.harvest() + val consoleKey = dev.lain.claudejb.process.ConsoleApiKey.harvest() ?: return vaulted + dev.lain.claudejb.process.ApiKeyApproval.approve(consoleKey) + ClaudeSettings.getInstance(project).setProviderApiKey(Provider.ANTHROPIC, consoleKey) + return true + } + /** EDT-only. Asks for the authorization code and feeds it to the running [ClaudeLoginFlow] (or cancels it). */ private fun promptForCode(ptyFlow: ClaudeLoginFlow) { val urlHint = authUrl?.let { "\n\nIf the browser didn't open, visit:\n$it" }.orEmpty() @@ -198,4 +433,13 @@ class LoginCoordinator( ptyFlow.submitCode(code.trim()) } } + + private companion object { + /** + * How long a submitted code may sit unanswered before the flow is cancelled and the card says so. + * Generous — the exchange is one HTTPS round-trip — but bounded: past this, waiting longer is not + * going to produce a different outcome. + */ + const val VERIFY_TIMEOUT_MS = 45_000 + } } diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt index ce2dd2c6..cc6418cc 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionControlClient.kt @@ -1,5 +1,6 @@ package dev.lain.claudejb.session +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.util.concurrency.AppExecutorUtil import dev.lain.claudejb.protocol.ClaudeEvent import kotlinx.serialization.json.JsonObject @@ -62,6 +63,17 @@ class SessionControlClient( /** In-flight control requests, keyed by request id; the value resolves the awaiting caller. */ private val pending = ConcurrentHashMap Unit>() + private val log = thisLogger() + + private companion object { + /** Trace truncation — enough to see every window/account field, not enough to flood idea.log. */ + const val TRACE_MAX = 2000 + } + + /** Best-effort subtype extraction from the request line, for the trace only. */ + private fun requestSubtype(line: String): String = + Regex("\"subtype\"\\s*:\\s*\"([a-z_]+)\"").find(line)?.groupValues?.get(1) ?: "?" + /** * Shared plumbing: register a pending handler keyed by a fresh id, arm the watchdog, send the request line, * and map the payload to [T] when the reply arrives. [decode] turns the (nullable) `response` payload into the @@ -83,11 +95,21 @@ class SessionControlClient( ClaudeEvent.ControlResult(requestId = id, success = false, payload = null, error = "control request timed out"), ) } + val requestLine = buildRequest(id) pending[id] = { res -> watchdog.cancel() - onResult(decode(res.payload)) + val decoded = decode(res.payload) + // The data-flow trace: what the binary ANSWERED and what our decode made of it. When a panel is + // empty, this line is the split between "the binary never sent it" and "we dropped it". + log.debug( + "CC-TRACE control reply ${requestSubtype(requestLine)} id=$id success=${res.success}" + + " err=${res.error ?: "-"} payload=${res.payload?.toString()?.take(TRACE_MAX) ?: "null"}" + + " -> decoded=${decoded?.toString()?.take(TRACE_MAX) ?: "null"}", + ) + onResult(decoded) } - write(buildRequest(id)) + log.debug("CC-TRACE control send ${requestSubtype(requestLine)} id=$id") + write(requestLine) } /** Resolves the pending handler correlated by [event].requestId. Unknown ids are ignored (watchdog may have won). */ diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt index b3cc647a..60e20a9a 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt @@ -10,6 +10,14 @@ import java.nio.file.Paths * `~/.claude/projects//.jsonl`. We never duplicate that content; readers above * ([SessionTitleReader], [SessionTranscriptReader]) parse these files on demand. * + * **READ-ONLY means read-only: this object does not delete, and neither does anything else in the plugin + * except the one credentials file it harvests** ([dev.lain.claudejb.process.CredentialsVault]). It used to + * offer a `delete(sessionId)` behind a "Delete Previous Session…" menu item, and that came out along with + * the recursive delete that destroyed a user's entire `~/.claude` — see the note on + * [dev.lain.claudejb.process.CredentialsVault] and `NoFileDeletionContractTest`. These files are the user's + * conversations and they are not ours to remove; whoever wants one gone can remove it themselves, where the + * decision is theirs and a mistake is theirs too. + * * All access is best-effort and confined to `~/.claude/projects`; every call tolerates a missing/locked * tree and returns null/empty rather than throwing. IO is blocking — call off the EDT. */ @@ -51,17 +59,6 @@ internal object SessionStore { /** Whether the binary still has a transcript for [sessionId]. */ fun exists(sessionId: String): Boolean = locate(sessionId) != null - /** - * Permanently deletes the `.jsonl` transcript under `~/.claude/projects`. Confined by the same - * [SAFE_ID] guard as [locate]: a non-UUID id (traversal attempt) is rejected before any filesystem access, - * so deletion can never escape the projects tree. Best-effort — returns true only if a file was actually - * removed; false on a bad id, absent file, or IO error. Blocking IO — call off the EDT. - */ - fun delete(sessionId: String): Boolean { - val file = locate(sessionId) ?: return false - return runCatching { Files.deleteIfExists(file) }.getOrDefault(false) - } - /** Raw JSONL lines for [sessionId], or null if the file is absent/unreadable. */ fun readLines(sessionId: String): List? = locate(sessionId)?.let { runCatching { Files.readAllLines(it) }.getOrNull() } diff --git a/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt b/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt index 7d17a451..d18308f2 100644 --- a/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt @@ -60,6 +60,16 @@ class ClaudeSettings(private val project: Project? = null) : PersistentStateComp @JvmField var customMcpServers: String = "" + /** + * The user pressed Log out and has not signed in since. + * + * Needed because the plugin will otherwise ride the binary's OWN login when it holds no credential + * of its own — which is what makes it work on macOS, and what would otherwise make Log out look + * broken: the safe is cleared, the binary's store is not, and the session simply starts again. + * Cleared by any successful sign-in. + */ + @JvmField var signedOut: Boolean = false + @JvmField var claudePath: String = "" @JvmField var nodePath: String = "" @@ -191,6 +201,17 @@ class ClaudeSettings(private val project: Project? = null) : PersistentStateComp } } + /** + * The Anthropic API key, in its OWN slot (`providerApiKey:anthropic`) like every other provider's — + * a DeepSeek key and an Anthropic key are separate entries and can never be mistaken for each other. + * + * Unlike a third-party provider this one carries NO base URL: it is an alternative first-party identity, + * so [Provider.launchEnv] rightly emits nothing for it. The key is applied at launch by + * [ClaudeSession.effectiveLaunchEnv], which is also where the subscription credential is resolved, so + * one place decides which identity a session runs as. + */ + val anthropicApiKey: String get() = getProviderApiKey(Provider.ANTHROPIC) + /** Env that routes the binary to the selected provider — empty for Anthropic (native auth). */ private fun providerEnv(): Map = Provider.launchEnv(provider, getProviderApiKey(provider)) diff --git a/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt b/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt new file mode 100644 index 00000000..7b1d90d1 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt @@ -0,0 +1,106 @@ +package dev.lain.claudejb.settings + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe + +/** + * The plugin's credentials, in the IDE's PasswordSafe (OS keychain / KWallet / DPAPI / encrypted file — + * whatever the user configured) and NOWHERE else. + * + * Why not `ClaudeSettings.envVars`, which can technically hold the same names: `claude-code.xml` is a + * PROJECT-level file, plain XML, and committable — an API key there is one careless `git add` from being + * published. The safe is application-level and encrypted, so a credential entered through the sign-in card + * can never reach the repository. + * + * Two entries, mutually exclusive by construction ([set] clears the sibling): a session authenticates with + * a subscription token OR an API key, and keeping both invites the confusion of not knowing which one the + * binary actually used. Values are injected into the child process ENVIRONMENT only + * (ClaudeSession.effectiveLaunchEnv) — never argv, never logs, never the transcript. + */ +object SecretStore { + + /** Env-var names the store manages. The name IS the key: what the binary reads is what we store under. */ + const val OAUTH_TOKEN = "CLAUDE_CODE_OAUTH_TOKEN" + + /** + * The env-var name for an API key — a NAME only. The key itself is NOT kept here: an Anthropic API key + * is stored exactly like every other provider's, through + * [ClaudeSettings.setProviderApiKey] under `providerApiKey:anthropic`, so the sign-in card and the + * provider field in Settings are two doors onto one credential instead of two credentials that quietly + * disagree about which one the binary used. + */ + const val API_KEY = "ANTHROPIC_API_KEY" + + /** + * NOT an env var: the full content of the binary's `.credentials.json`, held here AT REST. The file + * itself exists only while a session runs — [dev.lain.claudejb.process.CredentialsVault] materializes + * it at launch and harvests+deletes it at teardown, so the subscription login's disk footprint is zero + * whenever the plugin is idle. + */ + const val CREDENTIALS_JSON = "CLAUDE_CREDENTIALS_JSON" + + /** + * The signed-in account (email, organization) — NOT a credential and NOT an auth mode, which is exactly + * why it is kept out of [EXCLUSIVE]: storing it must never evict the credential beside it. Held here + * rather than re-read from `~/.claude.json` each time so the dashboard can name the account even once + * that file is gone. + */ + const val ACCOUNT_PROFILE = "CLAUDE_ACCOUNT_PROFILE" + + /** + * The last successful `claude auth status` reply, VERBATIM — the binary's own statement of who is signed + * in (`loggedIn`, `authMethod`, `apiProvider`, `email`, `orgId`, `orgName`, `subscriptionType`). + * + * Kept here, and kept whole, for two reasons. It is the authoritative source for the dashboard's account + * card: the probe is a process spawn, so it cannot run on every push, and without a stored copy the card + * had nothing to show between probes. And it is identity, not credential — so like [ACCOUNT_PROFILE] it + * stays out of [EXCLUSIVE] (storing it must never evict the credential beside it) and out of the child + * environment. + */ + const val AUTH_STATUS = "CLAUDE_AUTH_STATUS" + + /** Auth modes: mutually exclusive by construction — setting one clears the others. */ + private val EXCLUSIVE = listOf(OAUTH_TOKEN, CREDENTIALS_JSON) + + private val NAMES = EXCLUSIVE + ACCOUNT_PROFILE + AUTH_STATUS + + /** The subset that is injected into the child environment — [CREDENTIALS_JSON] is file-shaped, not env. */ + private val ENV_NAMES = listOf(OAUTH_TOKEN) + + private fun attributes(name: String) = + CredentialAttributes(generateServiceName("Claude Code", name)) + + fun get(name: String): String? = + PasswordSafe.instance.getPassword(attributes(name))?.takeIf { it.isNotBlank() } + + /** + * Stores [value] under [name] and CLEARS every sibling entry — the auth modes are exclusive, and a + * leftover credential from a previous mode silently winning over the one the user just set is exactly + * the kind of ghost this store exists to avoid. + */ + fun set(name: String, value: String) { + require(name in NAMES) { "unknown secret: $name" } + PasswordSafe.instance.set(attributes(name), Credentials(name, value)) + // Only an auth mode evicts the other auth modes. The account profile sits alongside whichever one + // is in use — clearing the credential every time we learned the user's email would be absurd. + if (name in EXCLUSIVE) EXCLUSIVE.filter { it != name }.forEach { clear(it) } + } + + fun clear(name: String) { + PasswordSafe.instance.set(attributes(name), null) + } + + fun clearAll() = NAMES.forEach(::clear) + + /** + * What the launch env should gain from the safe: every stored credential whose name the explicit env + * does NOT already define. The carve-out is the contract — a value the user wrote by hand in Settings + * (or exported in their shell) keeps winning over the card-entered one. + */ + fun envOverlay(explicitNames: Set): Map = + ENV_NAMES.filter { it !in explicitNames } + .mapNotNull { name -> get(name)?.let { name to it } } + .toMap() +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt new file mode 100644 index 00000000..5120e269 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt @@ -0,0 +1,172 @@ +package dev.lain.claudejb.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBPanel +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.JBTabsFactory +import com.intellij.ui.tabs.TabInfo +import com.intellij.ui.tabs.TabsListener +import com.intellij.util.ui.TimedDeadzone +import java.awt.BorderLayout +import javax.swing.Icon +import javax.swing.JComponent + +/** + * The chat tab strip, owned by the plugin instead of by the tool window. + * + * **Why not the tool window's own tabs.** The platform lays tool-window content tabs out with + * `TabContentLayout`, which does not scroll: once the labels no longer fit it simply stops drawing the + * earliest ones and buries them behind a `⌄` popup. With a handful of chats open the first ones vanish, which + * is the bug this class exists to fix. `JBTabs` — the same widget the editor uses — does scroll: its + * `createRowLayout()` returns a `ScrollableSingleRowLayout` whenever the tab list is single-row (verified + * against the platform, IU-262). So the tool window now holds ONE content, and every chat is a [TabInfo] in + * here. + * + * The surface is deliberately the small subset of `ContentManager` the tool window factory actually used + * (add / select / selected / list / listen), so the factory's logic — restore, attention badges, rename, + * fork — reads exactly as it did before and only the object it talks to changed. + * + * Disposal: each tab's panel is disposed when its tab is closed, and all of them when this panel is + * ([Disposable] — registered as the single content's disposer). + */ +internal class ChatTabsPanel(project: Project, parent: Disposable) : + JBPanel(BorderLayout()), Disposable { + + private val tabs: JBTabs = JBTabsFactory.createTabs(project, parent) + + /** What to run when a tab is closed by the user — the factory drops the session there. */ + private var onClosed: (TabInfo) -> Unit = {} + + private var onSelected: (TabInfo?) -> Unit = {} + + val selected: TabInfo? get() = tabs.selectedInfo + + /** The selected tab's chat panel, or null when the selected tab is not a chat (e.g. Diff History). */ + val selectedChat: JcefChatPanel? get() = tabs.selectedInfo?.component as? JcefChatPanel + + init { + // NB no Disposer.register here: this panel is the single content's disposer + // (`Content.setDisposer`), and registering it under the tool window as well would give one object two + // parents. [parent] is only what the tab widget itself is tied to. + add(tabs.component, BorderLayout.CENTER) + tabs.presentation.setSingleRow(true) // the scrolling layout; see the class doc + // The close button, ALWAYS drawn. `JBTabs` hides per-tab actions until the pointer is over the label by + // default, which on a chat strip reads as "the tabs have no close button" — you have to already know it + // is there to find it. The editor's own tabs show theirs unconditionally; so do these. + tabs.presentation.setTabLabelActionsAutoHide(false) + tabs.presentation.setTabLabelActionsMouseDeadzone(TimedDeadzone.NULL) + tabs.presentation.setTabDraggingEnabled(true) + // Compression OFF so a full strip cannot take the button away: `TabLabelLayout` drops the EAST component + // — which IS the action panel — whenever it has to fit a label into less than its preferred width + // (`layoutCompressible` bounds it to 0×0). Without compression the single-row layout scrolls instead, + // which is the whole reason this class uses `JBTabs`; see the class doc. + tabs.presentation.setSupportsCompression(false) + tabs.addListener( + object : TabsListener { + override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { + // A selected chat has no badge to show, and the keyboard focus belongs in its composer. + // The ContentManager used to do both as part of the selection; here it is explicit. + newSelection?.setIcon(null) + (newSelection?.component as? JcefChatPanel)?.focusInput() + onSelected(newSelection) + } + }, + ) + // Middle-click closes, the way every other tab strip in the IDE behaves. + tabs.addTabMouseListener( + object : java.awt.event.MouseAdapter() { + override fun mousePressed(e: java.awt.event.MouseEvent) { + if (e.button != java.awt.event.MouseEvent.BUTTON2) return + tabs.findInfo(e)?.let { close(it) } + } + }, + ) + } + + /** Registers the selection/close callbacks. Called once, by the factory, right after construction. */ + fun onEvents(selected: (TabInfo?) -> Unit, closed: (TabInfo) -> Unit) { + onSelected = selected + onClosed = closed + } + + /** + * Adds a tab for [component] and returns its handle. + * + * [disposer] is disposed when the tab is closed — the same contract as `Content.setDisposer`, and the + * reason a closed chat's JCEF browser and session actually go away instead of leaking. + */ + fun add(component: JComponent, title: String, tooltip: String, disposer: Disposable?): TabInfo { + val info = TabInfo(component).setText(title) + info.setObject(disposer) + info.setTabLabelActions(DefaultActionGroup(CloseTabAction(info)), TAB_ACTION_PLACE) + tabs.addTab(info) + applyTooltip(info, tooltip) + return info + } + + /** Selects [info], moving the keyboard focus into it (the selection listener does the focus transfer). */ + fun select(info: TabInfo) { + tabs.select(info, true) + } + + /** Closes [info]: removes the tab, fires the close callback and disposes whatever it carried. */ + fun close(info: TabInfo) { + tabs.removeTab(info) + onClosed(info) + (info.`object` as? Disposable)?.let { Disposer.dispose(it) } + } + + fun all(): List = tabs.tabs + + fun relabel(info: TabInfo, title: String, tooltip: String) { + info.setText(title) + applyTooltip(info, tooltip) + } + + /** + * The full title, on the tab's own label rather than through `TabInfo.setTooltipText`. + * + * That setter has two overloads and neither is usable across the supported range: the `String` one is + * DEPRECATED from 262, and the `HtmlChunk` one does not exist at the 251 floor — calling it would be a + * `NoSuchMethodError` on the oldest IDEs we claim to support. `TabLabel` falls through to + * `JPanel.getToolTipText`, so setting the label's own tooltip is the same result by a supported route. + */ + private fun applyTooltip(info: TabInfo, tooltip: String) { + (tabs.getTabLabel(info) as? JComponent)?.toolTipText = tooltip + } + + /** The attention badge. Ignored for the tab that is already on screen — it has nothing to catch up on. */ + fun badge(info: TabInfo, icon: Icon?) { + if (info !== tabs.selectedInfo) info.setIcon(icon) + } + + override fun dispose() { + tabs.tabs.forEach { info -> (info.`object` as? Disposable)?.let { Disposer.dispose(it) } } + } + + private inner class CloseTabAction(private val info: TabInfo) : + AnAction("Close Chat", "Close this conversation", AllIcons.Actions.Close) { + override fun actionPerformed(e: AnActionEvent) = close(info) + + /** + * EDT, and NOT because this action is slow: `ActionPanel` — the thing that turns a tab's action group + * into the little button — builds its buttons through a traverser that + * `filter { it.actionUpdateThread == ActionUpdateThread.EDT }`. `AnAction` answers `BGT` by default, so + * an action that does not say this is dropped on the floor and the tab is simply drawn without a close + * button, at any width, hovered or not. The platform's own editor-tab `CloseTab` declares it too. + */ + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + } + + private companion object { + /** Action place for the per-tab close button; any stable, plugin-owned string will do. */ + const val TAB_ACTION_PLACE = "ClaudeChatTabs" + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt b/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt index 48bcee7a..ae48b550 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt @@ -19,11 +19,8 @@ import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.SimpleListCellRenderer -import com.intellij.ui.content.Content import com.intellij.ui.content.ContentFactory -import com.intellij.ui.content.ContentManager -import com.intellij.ui.content.ContentManagerEvent -import com.intellij.ui.content.ContentManagerListener +import com.intellij.ui.tabs.TabInfo import dev.lain.claudejb.session.AttentionReason import dev.lain.claudejb.session.ChatSessionManager import dev.lain.claudejb.session.ClaudeSession @@ -41,6 +38,10 @@ import javax.swing.JList * Registers the right-anchored "Claude Code" tool window. Each conversation is a closeable tab (a * [JcefChatPanel] over its own [ClaudeSession]); "New chat" opens another, mirroring the web UI. The title * bar and gear menu act on whichever tab is selected. + * + * The tab strip is the plugin's own ([ChatTabsPanel]) rather than the tool window's: the platform's content + * tabs do not scroll, so past a handful of chats the earliest ones simply stopped being drawn. The tool + * window therefore holds exactly ONE content, and everything below talks to that strip. */ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { @@ -49,7 +50,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { // projects. The tool window, however, must be resolved per-project on demand ([resolveToolWindow]) — caching // it in a field would make a second project's window overwrite the first's and misdirect attention checks. /** Maps each live session to its tab, so a background session can target its own badge/notification. */ - private val contents = HashMap() + private val tabOf = HashMap() /** Per-session throttle for attention notifications (badge is never throttled). */ private val lastNotified = HashMap() @@ -58,40 +59,45 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { val manager = ChatSessionManager.getInstance(project) val cm = toolWindow.contentManager - cm.addContentManagerListener(object : ContentManagerListener { - override fun contentRemoved(event: ContentManagerEvent) { - (event.content.component as? JcefChatPanel)?.let { + val tabs = ChatTabsPanel(project, toolWindow.disposable) + tabs.onEvents( + selected = { info -> (info?.component as? JcefChatPanel)?.let { manager.setActive(it.session) } }, + closed = { info -> + (info.component as? JcefChatPanel)?.let { manager.remove(it.session) - contents.remove(it.session) + tabOf.remove(it.session) lastNotified.remove(it.session) } - } - - override fun selectionChanged(event: ContentManagerEvent) { - if (event.operation == ContentManagerEvent.ContentOperation.add) { - // The user is now looking at this tab — clear its attention badge. - event.content.setIcon(null) - (event.content.component as? JcefChatPanel)?.let { manager.setActive(it.session) } - } - } - }) + }, + ) + // ONE content, holding the whole strip. Not closeable: closing it would take every chat with it, and + // the tool window would be left showing nothing with no way back. + val content = ContentFactory.getInstance().createContent(tabs, "", false) + content.isCloseable = false + // The focus fix, unchanged in substance: tell the platform where this content's keyboard focus lives, + // resolved LAZILY against the selected tab (CEF's real input component does not exist yet, and the + // selected tab changes underneath). + content.setPreferredFocusedComponent { tabs.selectedChat?.focusTarget() } + content.setDisposer(tabs) + cm.addContent(content) - restoreOrCreate(project, cm, manager) + restoreOrCreate(project, tabs, manager) toolWindow.setTitleActions( listOf( - NewChatAction { openChat(project, cm, manager.create()) }, - InterruptAction(cm), - CommandsAction(cm), - DiffHistoryAction { openDiffHistory(project, cm) }, + SignOutAction(tabs), + NewChatAction { openChat(project, tabs, manager.create()) }, + InterruptAction(tabs), + CommandsAction(tabs), + DiffHistoryAction { openDiffHistory(project, tabs) }, CloseAllDiffsAction(project), ), ) - toolWindow.setAdditionalGearActions(buildGearGroup(project, cm)) + toolWindow.setAdditionalGearActions(buildGearGroup(project, tabs)) } /** Starts [session]'s process, then adds a tab for it and wires it. */ - private fun openChat(project: Project, cm: ContentManager, session: ClaudeSession) { + private fun openChat(project: Project, tabs: ChatTabsPanel, session: ClaudeSession) { // Launch the binary FIRST, before building the tab. `start()` only dispatches — it hands the blocking // work (env resolution sources a login shell, then the spawn) to a pooled thread and returns — so doing // it here means `claude` boots WHILE JCEF creates its browser, instead of waiting for it to finish. @@ -104,34 +110,21 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { session.start() val panel = JcefChatPanel(project, session) - val content = ContentFactory.getInstance().createContent(panel, tabTitle(session.title), false) - // Tell the platform WHERE the keyboard focus of this tab lives. Without it the ContentManager has nowhere - // to put focus when the tab is selected — a JBPanel isn't focusable — so the embedded browser never gets - // it: the composer looked alive but refused to take a click, and any focus round-trip through the IDE - // (a dialog closing, another tool window) dropped focus into the void and left the chat wedged. - // Resolved LAZILY (a Computable, not a fixed component): CEF's real input component doesn't exist yet at - // this point — the native browser is created later, when the tab is first shown. - content.setPreferredFocusedComponent { panel.focusTarget() } - content.isCloseable = true - content.description = session.title // full title as the tab tooltip - content.setDisposer(panel) - contents[session] = content + // The panel is the tab's disposer — same contract the Content had, and what makes a closed chat + // actually tear its JCEF browser down instead of leaking it. + val tab = tabs.add(panel, tabTitle(session.title), session.title, panel) + tabOf[session] = tab session.addListener(object : SessionListener { - override fun onAttention(reason: AttentionReason) = onSessionAttention(project, cm, session, reason) + override fun onAttention(reason: AttentionReason) = onSessionAttention(project, tabs, session, reason) override fun onTitleChanged() { - contents[session]?.let { - it.displayName = tabTitle(session.title) - it.description = session.title - } + tabOf[session]?.let { tabs.relabel(it, tabTitle(session.title), session.title) } } }) - cm.addContent(content) - // requestFocus = true: the ContentManager performs the focus transfer AS PART OF the selection — the same - // path a manual tab switch takes. Selecting without it and then asking for the focus ourselves loses the - // race: "New chat" is a toolbar action, and the platform restores focus to wherever it was when an action - // finishes, stepping on our request. (The caret itself is settled later, when the page is up — see - // JcefHost.markWebReady.) The trailing `true` IS requestFocus — a Java API, so it cannot be named here. - cm.setSelectedContent(content, true) + // Selecting transfers the keyboard focus as part of the selection — the same path a manual tab switch + // takes ([ChatTabsPanel]'s selection listener). Selecting and then asking for the focus separately + // loses the race: "New chat" is a toolbar action, and the platform restores focus to wherever it was + // when an action finishes. (The caret itself is settled later, when the page is up — JcefHost.markWebReady.) + tabs.select(tab) } /** @@ -140,13 +133,13 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { * currently is (working in the editor must NOT trigger a popup for the chat you're already looking at). * Otherwise badge the tab (always) and raise a throttled notification. Fired on the EDT. */ - private fun onSessionAttention(project: Project, cm: ContentManager, session: ClaudeSession, reason: AttentionReason) { + private fun onSessionAttention(project: Project, tabs: ChatTabsPanel, session: ClaudeSession, reason: AttentionReason) { val tw = resolveToolWindow(project) - val content = contents[session] ?: return - val onScreen = tw != null && tw.isVisible && cm.selectedContent?.component === content.component + val tab = tabOf[session] ?: return + val onScreen = tw != null && tw.isVisible && tabs.selected === tab if (onScreen) return - content.setIcon(AllIcons.General.Modified) + tabs.badge(tab, AllIcons.General.Modified) val now = System.currentTimeMillis() if (now - (lastNotified[session] ?: 0L) <= NOTIFY_THROTTLE_MS) return @@ -165,10 +158,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { ) .addAction( NotificationAction.createSimpleExpiring("Open") { - contents[session]?.let { - cm.setSelectedContent(it) - it.setIcon(null) - } + tabOf[session]?.let { tabs.select(it) } // selecting clears the badge, see ChatTabsPanel resolveToolWindow(project)?.activate(null) }, ) @@ -179,47 +169,45 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { private fun resolveToolWindow(project: Project): ToolWindow? = ToolWindowManager.getInstance(project).getToolWindow("Claude Code") - private fun activePanel(cm: ContentManager): JcefChatPanel? = cm.selectedContent?.component as? JcefChatPanel + private fun activePanel(tabs: ChatTabsPanel): JcefChatPanel? = tabs.selectedChat /** * Opens (or focuses) the Diff History tab for the active session. If a [DiffHistoryPanel] for that same session * is already open we just [DiffHistoryPanel.refresh] it and re-select its tab, so it always reflects edits made * since it was surfaced; otherwise a fresh closeable tab is created. No-op (with a hint) when no chat is open. */ - private fun openDiffHistory(project: Project, cm: ContentManager) { - val session = activePanel(cm)?.session ?: run { + private fun openDiffHistory(project: Project, tabs: ChatTabsPanel) { + val session = activePanel(tabs)?.session ?: run { Messages.showInfoMessage(project, "Open a chat first.", "Diff History") return } - val existing = cm.contents.firstOrNull { - (it.component as? DiffHistoryPanel)?.let { panel -> panel.boundSession === session } == true + val existing = tabs.all().firstOrNull { + (it.component as? DiffHistoryPanel)?.boundSession === session } if (existing != null) { (existing.component as DiffHistoryPanel).refresh() - cm.setSelectedContent(existing) + tabs.select(existing) return } val panel = DiffHistoryPanel(project, session) - val content = ContentFactory.getInstance().createContent(panel, "Diff History", false) - content.isCloseable = true - cm.addContent(content) - cm.setSelectedContent(content) + tabs.select(tabs.add(panel, "Diff History", "Diff History", null)) } - private fun buildGearGroup(project: Project, cm: ContentManager) = + private fun buildGearGroup(project: Project, tabs: ChatTabsPanel) = DefaultActionGroup().apply { // Context · Cost · Account · MCP all live in the formatted JCEF dashboard now — open that // instead of the old plain-text dialogs. - add(simple("Session Info (Context · Cost · Account · MCP)…") { activePanel(cm)?.openDashboard() }) - add(simple("Agents") { activePanel(cm)?.let { InfoDialogs.showAgents(project, it.session) } }) - add(simple("Binary Version…") { activePanel(cm)?.let { InfoDialogs.showBinaryVersion(project, it.session) } }) - add(simple("Effective Settings…") { activePanel(cm)?.let { InfoDialogs.showEffectiveSettings(project, it.session) } }) + add(simple("Session Info (Context · Cost · Account · MCP)…") { activePanel(tabs)?.openDashboard() }) + add(simple("Agents") { activePanel(tabs)?.let { InfoDialogs.showAgents(project, it.session) } }) + add(simple("Binary Version…") { activePanel(tabs)?.let { InfoDialogs.showBinaryVersion(project, it.session) } }) + add(simple("Effective Settings…") { activePanel(tabs)?.let { InfoDialogs.showEffectiveSettings(project, it.session) } }) addSeparator() - add(simple("Rename Session…") { renameActiveSession(project, cm) }) - add(simple("Fork Session") { forkActiveSession(project, cm) }) - add(simple("Open Previous Session…") { openPreviousSession(project, cm) }) - add(simple("Delete Previous Session…") { deletePreviousSession(project) }) - add(simple("Add Current File as @-context") { activePanel(cm)?.mentionCurrentFile() }) + add(simple("Rename Session…") { renameActiveSession(project, tabs) }) + add(simple("Fork Session") { forkActiveSession(project, tabs) }) + // No "Delete Previous Session…": the plugin does not delete the user's conversations. See + // SessionStore's KDoc and NoFileDeletionContractTest. + add(simple("Open Previous Session…") { openPreviousSession(project, tabs) }) + add(simple("Add Current File as @-context") { activePanel(tabs)?.mentionCurrentFile() }) add( simple("Settings…") { ShowSettingsUtil.getInstance().showSettingsDialog(project, ClaudeSettingsConfigurable::class.java) @@ -238,9 +226,9 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { * exists are skipped; only if there's genuinely nothing to restore does a fresh chat open. Restore can be turned * off in settings. The blocking session-file reads run on a pooled thread; tabs are opened back on the EDT. */ - private fun restoreOrCreate(project: Project, cm: ContentManager, manager: ChatSessionManager) { + private fun restoreOrCreate(project: Project, tabs: ChatTabsPanel, manager: ChatSessionManager) { if (!ClaudeSettings.getInstance(project).restoreOpenChatsOnStartup) { - openChat(project, cm, manager.create()) + openChat(project, tabs, manager.create()) return } ApplicationManager.getApplication().executeOnPooledThread { @@ -261,13 +249,13 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { } ApplicationManager.getApplication().invokeLater({ if (restored.isEmpty()) { - openChat(project, cm, manager.create()) + openChat(project, tabs, manager.create()) } else { for (r in restored) { val s = manager.create() s.title = r.title ?: s.title s.restore(r.id, r.entries) - openChat(project, cm, s) + openChat(project, tabs, s) } } }, ModalityState.any()) @@ -279,8 +267,8 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { * the binary's `/rename` (persisting a `customTitle` line) and relabels the tab via the title-changed listener. * No-op when there's no active chat or the input is blank/unchanged. */ - private fun renameActiveSession(project: Project, cm: ContentManager) { - val session = activePanel(cm)?.session ?: return + private fun renameActiveSession(project: Project, tabs: ChatTabsPanel) { + val session = activePanel(tabs)?.session ?: return val input = Messages.showInputDialog( project, "New session name:", @@ -299,8 +287,8 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { * fork reuses the source `sessionId`, so [openChat]'s `start()` re-attaches via `--resume`; the binary branches * the conversation once the new tab sends its first message. No-op when there's no active session id yet. */ - private fun forkActiveSession(project: Project, cm: ContentManager) { - val source = activePanel(cm)?.session ?: return + private fun forkActiveSession(project: Project, tabs: ChatTabsPanel) { + val source = activePanel(tabs)?.session ?: return val sourceId = source.sessionId ?: run { Messages.showInfoMessage(project, "This session hasn't been initialized yet — nothing to fork.", "Claude Code") return @@ -317,7 +305,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { val s = manager.create() s.title = "$sourceTitle (fork)" s.restore(sourceId, entries) - openChat(project, cm, s) + openChat(project, tabs, s) }, ModalityState.any()) } } @@ -329,7 +317,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { * the binary re-attach via `--resume` automatically. The blocking session-file reads run on a pooled thread; * the popup and tab opening happen on the EDT. */ - private fun openPreviousSession(project: Project, cm: ContentManager) { + private fun openPreviousSession(project: Project, tabs: ChatTabsPanel) { ApplicationManager.getApplication().executeOnPooledThread { val refs = SessionTranscriptReader.listSessions(project) ApplicationManager.getApplication().invokeLater({ @@ -354,56 +342,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { val s = manager.create() s.title = ref.title s.restore(ref.sessionId, entries) - openChat(project, cm, s) - }, ModalityState.any()) - } - } - .setRequestFocus(true) - .createPopup() - .showCenteredInCurrentWindow(project) - }, ModalityState.any()) - } - } - - /** - * Lets the user permanently delete a past session's transcript: shows the same rich chooser, then on pick asks - * for confirmation and calls [SessionStore.delete] (UUID-guarded — can never escape `~/.claude/projects`). The - * delete IO runs off the EDT. This removes the binary's source-of-truth file, so the session disappears from - * every "previous session" list and can no longer be resumed. - */ - private fun deletePreviousSession(project: Project) { - ApplicationManager.getApplication().executeOnPooledThread { - val refs = SessionTranscriptReader.listSessions(project) - ApplicationManager.getApplication().invokeLater({ - if (refs.isEmpty()) { - Messages.showInfoMessage(project, "No previous sessions to delete.", "Claude Code") - return@invokeLater - } - JBPopupFactory.getInstance() - .createPopupChooserBuilder(refs) - .setTitle("Delete Previous Session") - .setRenderer(SessionRefRenderer()) - .setItemChosenCallback { ref -> - val ok = Messages.showYesNoDialog( - project, - "Permanently delete the session \"${ref.title}\"?\n" + - "This removes its transcript and it can no longer be resumed.", - "Delete Session", - "Delete", - "Cancel", - Messages.getWarningIcon(), - ) - if (ok != Messages.YES) return@setItemChosenCallback - ApplicationManager.getApplication().executeOnPooledThread { - val deleted = SessionStore.delete(ref.sessionId) - ApplicationManager.getApplication().invokeLater({ - if (!deleted) { - Messages.showErrorDialog( - project, - "Could not delete the session file.", - "Delete Session", - ) - } + openChat(project, tabs, s) }, ModalityState.any()) } } @@ -476,9 +415,32 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { override fun actionPerformed(e: AnActionEvent) = onNew() } - private class InterruptAction(private val cm: ContentManager) : + /** + * Signs out of Claude, from the tool window's own title bar. + * + * It lives here rather than in the web UI because the composer's readout is a wrapping flex row of + * metrics: a button at its end drops onto a second line as soon as the numbers fill the width. The title + * bar is on screen at all times, never reflows, and is where an IDE user looks for a tool window's own + * controls. The dashboard's account row keeps its Log out — same message, two doors. + */ + private class SignOutAction(private val tabs: ChatTabsPanel) : + AnAction("Log out", "Sign out of Claude — stops the session and returns to the sign-in card", AllIcons.Actions.Exit) { + override fun actionPerformed(e: AnActionEvent) { + tabs.selectedChat?.requestLogout() + } + + override fun update(e: AnActionEvent) { + // Greyed on a non-chat tab (Diff History), where there is no session to sign out of. + e.presentation.isEnabled = tabs.selectedChat != null + } + + /** EDT, for the same selection-state data race spelled out in [InterruptAction]. */ + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + } + + private class InterruptAction(private val tabs: ChatTabsPanel) : AnAction("Interrupt", "Stop the current turn", AllIcons.Actions.Suspend) { - private fun session(): ClaudeSession? = (cm.selectedContent?.component as? JcefChatPanel)?.session + private fun session(): ClaudeSession? = tabs.selectedChat?.session override fun actionPerformed(e: AnActionEvent) { session()?.interrupt() } @@ -487,20 +449,19 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { } /** - * EDT **deliberately**, not an oversight. [session] reads `ContentManager.getSelectedContent()`, which is - * `ContentManagerImpl.mySelection` — a plain `ArrayList` mutated on the EDT, with no internal - * synchronization and no threading assertion to warn you. Reading it from a background thread is a data - * race whose worst case is not a stale label but an `IndexOutOfBoundsException`: `isEmpty()` says no, - * the EDT clears the selection, `get(0)` throws. Moving this to BGT to silence an - * "N ms to grab EDT" warning would trade a cosmetic log line for a real (if rare) crash. + * EDT **deliberately**, not an oversight. [session] reads the tab strip's current selection, which is + * Swing state mutated on the EDT with no internal synchronization and no threading assertion to warn + * you. Reading it from a background thread is a data race whose worst case is not a stale label but an + * exception mid-iteration. Moving this to BGT to silence an "N ms to grab EDT" warning would trade a + * cosmetic log line for a real (if rare) crash. */ override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT } - private class CommandsAction(private val cm: ContentManager) : + private class CommandsAction(private val tabs: ChatTabsPanel) : AnAction("Commands", "Browse all slash commands", AllIcons.Actions.Find) { override fun actionPerformed(e: AnActionEvent) { - (cm.selectedContent?.component as? JcefChatPanel)?.showCommandPalette() + tabs.selectedChat?.showCommandPalette() } } @@ -550,19 +511,17 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { */ fun openDiffHistoryFor(project: Project, session: ClaudeSession) { val tw = com.intellij.openapi.wm.ToolWindowManager.getInstance(project).getToolWindow("Claude Code") ?: return - val cm = tw.contentManager - val existing = cm.contents.firstOrNull { + // The tool window holds ONE content: the tab strip. Everything the user sees is a tab inside it. + val tabs = tw.contentManager.contents.firstNotNullOfOrNull { it.component as? ChatTabsPanel } ?: return + val existing = tabs.all().firstOrNull { (it.component as? DiffHistoryPanel)?.boundSession === session } if (existing != null) { (existing.component as DiffHistoryPanel).refresh() - cm.setSelectedContent(existing) + tabs.select(existing) } else { val panel = DiffHistoryPanel(project, session) - val content = ContentFactory.getInstance().createContent(panel, "Diff History", false) - content.isCloseable = true - cm.addContent(content) - cm.setSelectedContent(content) + tabs.select(tabs.add(panel, "Diff History", "Diff History", null)) } tw.activate(null) } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index f3e630f7..eef06b14 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -91,6 +91,28 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : private var lastUsage: dev.lain.claudejb.protocol.UsageReport? = null private var lastUsageAt = 0L + /** + * Plan-limits poll. Unlike context and cost — which cannot move while the session idles, so their timer + * retires at turn end — the quota IS shared state: other sessions, other devices and claude.ai itself + * consume the same windows, and a window reset is a wall-clock event. So this ticks for the panel's whole + * lifetime, gated on [isShowing]: a background tab skips the round-trip and catches up within one tick of + * being brought forward. + */ + private val usageTimer = Timer(USAGE_POLL_MS) { if (isShowing) requestUsage() }.apply { isRepeats = true } + + /** Last observed process liveness, so [onStateChanged] can spot a restart. EDT-confined. */ + private var wasRunning = false + + /** Everything that is per-PROCESS rather than per-panel: asked on every launch, not just the first. */ + private fun onSessionReady() { + requestMcp() + requestVersion() + requestUsage() + } + + /** The two onboarding cards' host side (install-the-binary + sign-in), kept OFF this class on purpose. */ + private val onboarding = OnboardingController(project, session, host::exec) + init { background = ChatTheme.BG add(host.component, BorderLayout.CENTER) @@ -98,6 +120,7 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : livePanels.add(this) session.transcript.addListener(this) session.addListener(this) + session.attachLoginUi(onboarding) // the sign-in card renders in this panel's web view // Re-push the theme whenever the IDE's Look-and-Feel changes; tied to this panel's lifetime. val lafConn = ApplicationManager.getApplication().messageBus.connect(this) @@ -112,9 +135,8 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : pushSession() // These three all need a live `claude` process, and the panel is constructed BEFORE session.start() // runs — so calling them directly here always lost. See [whenReady]. - whenReady(::requestMcp) - whenReady(::requestVersion) - whenReady(::requestUsage) + whenReady(::onSessionReady) + usageTimer.start() structural = true ensureTimer() } @@ -174,8 +196,18 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : pushMetaState() pushSession() drainPendingUntilReady() + // A RESTART is a new process, so everything that is only asked once per process has to be asked + // again. [whenReady] fires once in the constructor and never again, so after a sign-out/sign-in the + // dashboard sat empty until a prompt happened to produce a rate_limit_event — the panels looked + // broken when they had simply never been asked. + val running = session.isRunning() + if (running && !wasRunning) onSessionReady() + wasRunning = running // A window moved (a rate_limit_event landed) → re-ask for all of them. requestUsage throttles itself. if (session.rateLimits.isNotEmpty()) requestUsage() + // The not-found card is up → the onboarding watcher looks for the binary appearing (an install + // finishing) and starts the session without further clicks. + onboarding.onStateChanged() } /** @@ -348,15 +380,20 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** Push the session-dashboard data (context categories, cost, account, subagents) to the web view. */ private fun pushSession() { - host.exec("window.cc.session && window.cc.session(" + JcefSessionData.sessionJson(session, lastUsage) + ")") + val json = JcefSessionData.sessionJson(session, lastUsage) + // The host→web half of the data-flow trace: this is EXACTLY what the dashboard receives. An empty + // panel with a full CC-TRACE control reply means the loss is between the session cache and here. + LOG.debug("CC-TRACE pushSession ${json.take(TRACE_MAX)}") + host.exec("window.cc.session && window.cc.session($json)") } /** * Refreshes the plan-limit windows, then re-pushes the dashboard. * - * Throttled rather than polled: the trigger is a `rate_limit_event` (the binary telling us a window moved) - * or the dashboard being opened, and never a timer. The windows reset on the hour scale, so a periodic - * refresh would be network traffic in service of a number nobody is watching change. + * Called by [usageTimer] every [USAGE_POLL_MS] while the panel is showing, and directly on the event + * triggers (a `rate_limit_event`, the dashboard opening, session ready). The throttle below is burst + * protection for the event triggers — a run of rate_limit_events must not turn into a request storm — + * and its floor sits under the timer's period so the periodic tick is never swallowed by it. */ private fun requestUsage() { val now = System.currentTimeMillis() @@ -595,6 +632,16 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : } is JcefBridge.Msg.StopTask -> session.stopTask(m.taskId) + + // Everything the two onboarding cards send (install / binary path / sign-in / logout) lives in + // its own collaborator — see OnboardingController. `handle` returns false only for messages that + // are not onboarding's, and every remaining SessionControl IS handled above, so falling through + // here means a new message was added without a handler: surface it instead of ignoring it. + else -> { + val handled = onboarding.handle(m) + if (!handled) logger.warn("unhandled session-control message: $m") + Unit + } } private fun onLifecycle(m: JcefBridge.Msg.Lifecycle) = when (m) { @@ -780,6 +827,13 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : fun showCommandPalette() = host.exec("window.cc.openPalette && window.cc.openPalette()") + /** + * Signs out — the same route the dashboard's account row takes, so there is exactly ONE logout sequence. + * It is delicate (stop the process first, then clear, then start into a session with no identity) and + * lives commented in [OnboardingController.logout]; this is a delegate, never a second copy of it. + */ + fun requestLogout() = onboarding.logout() + /** Pins the current editor file as a removable attachment chip (editor "Add … to Claude Context"). */ fun mentionCurrentFile() { val path = EditorContextProvider.currentFilePath(project) ?: return @@ -835,11 +889,19 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : livePanels.remove(this) session.transcript.removeListener(this) session.removeListener(this) + session.detachLoginUi(onboarding) + onboarding.dispose() timer.stop() + usageTimer.stop() // host disposes via the parentDisposable (this panel) registered in JcefHost. } private companion object { + private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(JcefChatPanel::class.java) + + /** Trace truncation for CC-TRACE lines; matches SessionControlClient's. */ + private const val TRACE_MAX = 2000 + private val BTW = Regex("^/btw\\b.*") // Files larger than this skip the EDT-side hunk read/diff for hunk-by-hunk review (full accept still works). @@ -851,8 +913,15 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** How many recently-opened files the attach menu offers before the user has to search. */ private const val RECENT_FILES_LIMIT = 14 - /** Floor between `get_usage` round-trips. The windows move on the hour scale; this is generous. */ - private const val USAGE_MIN_INTERVAL_MS = 30_000L + /** Period of the plan-limits poll while the panel is visible. */ + private const val USAGE_POLL_MS = 15_000 + + /** + * Floor between `get_usage` round-trips — burst protection for the event-driven triggers. MUST stay + * below [USAGE_POLL_MS], or the periodic tick is silently throttled away and the poll only *looks* + * like it runs every 15 s. + */ + private const val USAGE_MIN_INTERVAL_MS = 12_000L // Vibe Mode is global (ChatTheme.vibeMode), so a toggle on one tab must re-theme them all. private val livePanels = java.util.concurrent.CopyOnWriteArrayList() diff --git a/src/main/kotlin/dev/lain/claudejb/ui/OnboardingController.kt b/src/main/kotlin/dev/lain/claudejb/ui/OnboardingController.kt new file mode 100644 index 00000000..00cdc1c2 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/OnboardingController.kt @@ -0,0 +1,279 @@ +package dev.lain.claudejb.ui + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import dev.lain.claudejb.process.AccountProfile +import dev.lain.claudejb.process.ApiKeyApproval +import dev.lain.claudejb.process.AuthCli +import dev.lain.claudejb.process.BinaryInstall +import dev.lain.claudejb.process.ClaudeBinaryLocator +import dev.lain.claudejb.process.CredentialsVault +import dev.lain.claudejb.process.TerminalLauncher +import dev.lain.claudejb.session.ClaudeSession +import dev.lain.claudejb.session.LoginCoordinator +import dev.lain.claudejb.settings.ClaudeSettings +import dev.lain.claudejb.settings.Provider +import dev.lain.claudejb.settings.SecretStore +import dev.lain.claudejb.ui.jcef.JcefBridge +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import javax.swing.Timer + +/** + * Everything the two onboarding cards need from the host: installing the binary, validating a manual path, + * watching for an install to finish, and the sign-in flow's host side ([LoginCoordinator.LoginUi]). + * + * A collaborator of [JcefChatPanel] rather than more methods ON it — the panel stays the thin assembler the + * architecture demands, and this file owns one concern end to end. [exec] is the panel's `host.exec`. + */ +internal class OnboardingController( + private val project: Project, + private val session: ClaudeSession, + private val exec: (String) -> Unit, +) : LoginCoordinator.LoginUi { + + /** + * Always running, not just while a card is up: which screen this tab owes the user is a question about + * the world — is the binary installed, do we hold a credential — and the world changes while the tab + * sits there. Answering it once at construction is what made an install or a sign-in performed outside + * the card require closing and reopening the tab to take effect. + * + * The check is silent and cheap (a filesystem stat and a safe read, no process spawn), and + * [ClaudeSession.refreshBootState] returns immediately once a session is running. + */ + private val bootWatcher = Timer(BOOT_WATCH_MS) { tick() }.apply { + isRepeats = true + start() + } + + private fun tick() { + // Runs while the session is UP too, and that is the point: losing the binary or the credential has + // to walk the flow backwards (stop the process, show the matching screen), not sit there with a + // chat whose identity is gone. + // BLOCKING: stats the filesystem and reads the PasswordSafe (a keychain round-trip on some hosts). + ApplicationManager.getApplication().executeOnPooledThread { session.refreshBootState() } + } + + /** + * Panel state push. The watcher runs unconditionally now, so there is nothing to start or stop — the + * only thing left is announcing an install we launched, once, when the binary actually turns up. The + * terminal tab running the installer may well be covering the chat, so a notification is the only place + * the user reliably sees it. + */ + fun onStateChanged() { + if (installLaunched && !session.binaryMissing) { + installLaunched = false + notifyInfo("Claude Code installed", "The claude binary was found — starting the session.") + } + } + + fun dispose() = bootWatcher.stop() + + fun handle(m: JcefBridge.Msg.SessionControl): Boolean { + when (m) { + is JcefBridge.Msg.InstallClaude -> runInstaller(m.method) + + is JcefBridge.Msg.SetBinaryPath -> validateAndUseBinaryPath(m.path) + + JcefBridge.Msg.RecheckBinary -> recheckBinary(announceFailure = true) + + JcefBridge.Msg.LoginSubscription -> { + pushAuthState("waiting") + session.startLogin(LoginCoordinator.Mode.SUBSCRIPTION) + } + + JcefBridge.Msg.LoginConsole -> { + pushAuthState("waiting") + session.startLogin(LoginCoordinator.Mode.CONSOLE) + } + + is JcefBridge.Msg.UseApiKey -> useApiKey(m.key) + + is JcefBridge.Msg.SubmitLoginCode -> { + pushAuthState("verifying") + session.submitLoginCode(m.code) + } + + JcefBridge.Msg.CancelLogin -> { + session.cancelLogin() + pushAuthState("idle") + } + + JcefBridge.Msg.DismissAuth -> session.dismissLoginCard() + + JcefBridge.Msg.Logout -> logout() + + else -> return false + } + return true + } + + // ── missing-binary card ────────────────────────────────────────────────────────────────────────────── + + /** Runs the chosen official installer in the IDE terminal, where the user watches every line of it. */ + private fun runInstaller(methodId: String) { + val method = BinaryInstall.method(methodId) ?: return + val launched = TerminalLauncher.isAvailable() && + TerminalLauncher.openAndRunCommand(project, method.argv, "Install Claude Code") + if (!launched) { + // No Terminal plugin: the card's `display` text is the fallback — tell the user to run it + // themselves rather than silently doing nothing. + pushBootError("The IDE terminal is unavailable — run this in a shell: ${method.display}") + return + } + // The installer is now running in a terminal tab; the watcher is already looking. All this records is + // that we owe the user a "it worked" when the binary shows up. + installLaunched = true + } + + /** Set while an installer we launched is running, so its success can be announced exactly once. */ + private var installLaunched = false + + /** + * Validates a user-typed path OFF the EDT (it runs `--version`, seconds on a cold start), then either + * persists it and starts the session, or puts the reason on the card. + */ + private fun validateAndUseBinaryPath(rawPath: String) { + ApplicationManager.getApplication().executeOnPooledThread { + val verdict = BinaryInstall.validate(rawPath) + ApplicationManager.getApplication().invokeLater { + when (verdict) { + is BinaryInstall.Validation.Ok -> { + ClaudeSettings.getInstance(project).state.claudePath = verdict.binary.absolutePath + session.start() + } + + is BinaryInstall.Validation.Invalid -> pushBootError(verdict.reason) + } + } + } + } + + /** + * The card's "Check again" button. The periodic watcher does the same work silently; this exists so an + * impatient click gets an answer instead of nothing, and so a failure can be said out loud once. + */ + private fun recheckBinary(announceFailure: Boolean) { + ApplicationManager.getApplication().executeOnPooledThread { + session.refreshBootState() + val found = ClaudeBinaryLocator.locate(ClaudeSettings.getInstance(project).claudePath) != null + if (found || !announceFailure) return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + pushBootError("Still not found. If the install just finished, give it a moment — this card checks again on its own.") + } + } + } + + private fun pushBootError(message: String) { + exec("window.cc.bootPathError && window.cc.bootPathError(" + JcefBridge.jsString(message) + ")") + } + + // ── sign-in card (LoginCoordinator.LoginUi) ────────────────────────────────────────────────────────── + + /** Host → card: one method moves the whole card; the card is a pure function of `{step,url,message}`. */ + private fun pushAuthState(step: String, url: String? = null, message: String? = null) { + val payload = buildJsonObject { + put("step", JsonPrimitive(step)) + url?.let { put("url", JsonPrimitive(it)) } + message?.let { put("message", JsonPrimitive(it)) } + } + exec("window.cc.authState && window.cc.authState($payload)") + } + + override fun onAuthUrl(url: String) = pushAuthState("url", url = url) + + override fun onCodeRequested() = pushAuthState("code") + + override fun onLoginResult(success: Boolean, message: String) { + // Success needs no card step: the restart's state push clears needsLogin and the card falls away. + if (!success) pushAuthState("error", message = message) + } + + /** + * The card's API-key route: the key goes to the IDE's PasswordSafe ([SecretStore]) — application-level + * and encrypted, never the project-level XML — and the session relaunches with it in the environment. + * The value exists in this method and the safe, nowhere else: not logged, not echoed, not persisted in + * settings. + */ + private fun useApiKey(key: String) { + val trimmed = key.trim() + if (trimmed.isEmpty()) { + pushAuthState("error", message = "Enter an API key first.") + return + } + pushAuthState("verifying") + // Off-EDT: this both writes a file and runs the binary. + ApplicationManager.getApplication().executeOnPooledThread { + // Record the approval BEFORE validating — the probe is itself a non-interactive run, so an + // unapproved key would fail it for the same reason it failed every turn. + ApiKeyApproval.approve(trimmed) + val binary = ClaudeBinaryLocator.locate(ClaudeSettings.getInstance(project).claudePath) + val state = binary?.let { AuthCli.status(it, mapOf(SecretStore.API_KEY to trimmed)) } + ApplicationManager.getApplication().invokeLater { + if (state != null && !state.loggedIn) { + // Never file a credential the binary just refused: it would come back as a failed turn + // on every launch, with nothing on screen tying it to the key that was typed. + pushAuthState("error", message = "That API key was refused. Check it and try again.") + return@invokeLater + } + // Its own provider slot — the same one Settings ▸ Provider uses, so the card and that field + // are two doors onto one credential. A DeepSeek key lives under its own id and is untouched. + ClaudeSettings.getInstance(project).setProviderApiKey(Provider.ANTHROPIC, trimmed) + ClaudeSettings.getInstance(project).state.signedOut = false + session.dismissLoginCard() + session.restart() + } + } + } + + /** + * Log out = every place a credential of ours can be: the IDE safe (API key, and the vaulted + * credentials file) and any file the vault materialized on disk. + * + * Deliberately NOT `claude auth logout`. The plugin's credentials live in the safe and only visit the + * disk while a session runs ([CredentialsVault]) — clearing the safe IS the logout. Shelling out to + * the binary would additionally destroy whatever the user's own terminal CLI had, which is not this + * button's business. Off-EDT for the file work; the restart's probe raises the sign-in card again. + */ + internal fun logout() { + // STOP FIRST, and this order is the whole correctness of the button. + // + // The running binary holds the old identity, so leaving it alive means "signed out" while the very + // next turn still works. Worse, `stop()` harvests the credentials file back into the safe — so + // clearing first and stopping afterwards could put the credential straight back and silently undo + // the logout. Stop, then clear, then start into a session that has nothing to run as. + // + // The flag goes first and synchronously: the boot watcher ticks every few seconds and would happily + // relaunch the session in the window between stopping it and the credential actually being cleared. + ClaudeSettings.getInstance(project).state.signedOut = true + session.stop() + ApplicationManager.getApplication().executeOnPooledThread { + SecretStore.clearAll() + CredentialsVault.clear() + AccountProfile.invalidate() + // The Anthropic key too — it is one of this plugin's identities. Other providers' keys are NOT + // cleared: signing out of Claude is not a reason to lose an unrelated DeepSeek credential. + ClaudeSettings.getInstance(project).setProviderApiKey(Provider.ANTHROPIC, "") + ApplicationManager.getApplication().invokeLater { + notifyInfo("Signed out of Claude", "Stored credentials were removed from the IDE.") + // Finds no credential and raises the sign-in card instead of launching. See + // ClaudeSession.hasCredential. + session.start() + } + } + } + + private fun notifyInfo(title: String, message: String) { + NotificationGroupManager.getInstance() + .getNotificationGroup("Claude Code") + .createNotification(title, message, NotificationType.INFORMATION) + .notify(project) + } + + private companion object { + /** Cadence of the missing-binary watcher: pure file-existence checks, no process spawn. */ + const val BOOT_WATCH_MS = 3_000 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt index 9b3e89d2..fa80db26 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt @@ -235,6 +235,29 @@ object JcefBridge { data class McpReconnect(val name: String) : SessionControl data class McpToggle(val name: String, val enabled: Boolean) : SessionControl data class StopTask(val taskId: String) : SessionControl + + // The "Claude Code was not found" boot card: run an official installer in the IDE terminal, + // validate a user-typed binary path, or re-check after an install finished. + data class InstallClaude(val method: String) : SessionControl + data class SetBinaryPath(val path: String) : SessionControl + object RecheckBinary : SessionControl + + // The sign-in card and the dashboard's account button. The two credential-bearing messages + // (UseApiKey, SubmitLoginCode) carry SECRETS: they cross the in-memory JCEF bridge only, and their + // values must never be logged, echoed into state pushes, or appear in any error text. + object LoginSubscription : SessionControl + + /** + * Sign in against Anthropic Console (API-usage billing) rather than a personal subscription — the + * route organisations need: the consent includes `org:create_api_key`, so a corporate account is + * provisioned by signing in instead of by distributing a pasted key. + */ + object LoginConsole : SessionControl + data class UseApiKey(val key: String) : SessionControl + data class SubmitLoginCode(val code: String) : SessionControl + object CancelLogin : SessionControl + object DismissAuth : SessionControl + object Logout : SessionControl } /** Typed accessors over one inbound payload, so the per-group parsers below read as plain field reads. */ @@ -247,6 +270,13 @@ object JcefBridge { fun json(key: String): JsonObject? = obj[key] as? JsonObject } + /** + * A string as a JS expression: a JSON string literal is a valid JavaScript string literal, and the + * serializer's escaping (quotes, backslashes, control characters) is exactly what stops a message that + * happens to contain `")` from breaking out of the `host.exec` call that embeds it. + */ + fun jsString(s: String): String = JsonPrimitive(s).toString() + /** * Parses one `window.__ccSend` payload. Malformed input or an unrecognized `type` maps to [Msg.Unknown]. * @@ -335,8 +365,30 @@ object JcefBridge { private fun parseSessionControls(type: String, f: Fields): Msg? = when (type) { "mcpReconnect" -> Msg.McpReconnect(f.text("name")) + "mcpToggle" -> Msg.McpToggle(f.text("name"), f.bool("enabled")) + "stopTask" -> Msg.StopTask(f.text("taskId")) + + // The "Claude Code was not found" boot card. + "installClaude" -> Msg.InstallClaude(f.text("method")) + + "setBinaryPath" -> Msg.SetBinaryPath(f.text("path")) + + "recheckBinary" -> Msg.RecheckBinary + + else -> parseAuthControls(type, f) + } + + /** The sign-in card and the account buttons. Split out of [parseSessionControls] for complexity only. */ + private fun parseAuthControls(type: String, f: Fields): Msg? = when (type) { + "loginSubscription" -> Msg.LoginSubscription + "loginConsole" -> Msg.LoginConsole + "useApiKey" -> Msg.UseApiKey(f.text("key")) + "submitLoginCode" -> Msg.SubmitLoginCode(f.text("code")) + "cancelLogin" -> Msg.CancelLogin + "dismissAuth" -> Msg.DismissAuth + "logout" -> Msg.Logout else -> null } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt index ab1ecb1d..c27b6a49 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt @@ -126,9 +126,13 @@ object JcefSessionData { put("limitReached", extra.spendLimitReached) } - /** The wire reports 0..100 here, but has historically also sent 0..1; accept both, clamp, never crash. */ - private fun pctOf(raw: Double): Int = - (if (raw <= 1.0) raw * 100 else raw).toInt().coerceIn(0, 100) + /** + * The wire scale is 0..100 (the SDK documents every `get_usage` window as "Percentage of the window + * used, 0-100"); clamp, never crash. The former "accept 0..1 too" heuristic is deliberately gone: it + * was undecidable at exactly 1.0 and rendered a genuine 1% as 100% — observed live, and reachable by + * every user at the start of every freshly reset window. Same rule as [RateLimitInfo.utilizationPercent]. + */ + private fun pctOf(raw: Double): Int = Math.round(raw).toInt().coerceIn(0, 100) /** Epoch seconds → ISO-8601, so event-sourced windows match the shape `get_usage` already returns. */ private fun isoOf(epochSeconds: Long): String = @@ -203,20 +207,63 @@ object JcefSessionData { return null } - /** `{ email, org, plan, provider }` or null when the account is empty (no fields reported). */ + /** + * `{ email, org, plan, provider, loggedIn }` — session-reported account fields, enriched by the + * `auth status` probe ([ClaudeSession.authCliStatus]), which also knows about a session that has no + * account because it is NOT signed in. `loggedIn` drives the dashboard's Sign in / Log out button: + * absent (null) when unknown, so the button doesn't claim a state nobody verified. + */ private fun accountJson(session: ClaudeSession): JsonObject? { val acct = session.account + val probe = session.authCliStatus + // The stored `auth status` reply — the binary's own words, filed in the safe by the last probe. The + // probe is a process spawn and cannot run on every push, so without this the card had nothing to show + // between probes and the Email / Organization rows sat empty. + val stored = dev.lain.claudejb.process.AuthCli.stored() val empty = acct.email.isBlank() && acct.organization.isBlank() && - acct.subscriptionType.isBlank() && acct.apiProvider.isBlank() + acct.subscriptionType.isBlank() && acct.apiProvider.isBlank() && probe == null && stored == null if (empty) return null return buildJsonObject { - put("email", acct.email.ifBlank { null }) - put("org", acct.organization.ifBlank { null }) - put("plan", acct.subscriptionType.ifBlank { null }) - put("provider", acct.apiProvider.ifBlank { null }) + put("email", firstPresent(acct.email, probe?.email, stored?.email)) + put("org", firstPresent(acct.organization, probe?.orgName, stored?.orgName)) + // Last resort, the vaulted blob: the plan is also carried inside the credential we hold, so the + // row survives even a session that never managed to probe. + put( + "plan", + firstPresent( + acct.subscriptionType, + probe?.subscriptionType, + stored?.subscriptionType, + dev.lain.claudejb.process.CredentialsVault.subscriptionType(), + ), + ) + // `apiProvider` ("firstParty") before `authMethod` ("claude.ai"): both describe the route, and the + // former is the one the session's own account event uses, so the row can't change vocabulary + // depending on which source answered. + put( + "provider", + firstPresent( + acct.apiProvider, + probe?.apiProvider, + probe?.authMethod, + stored?.apiProvider, + stored?.authMethod, + ), + ) + // The stored reply counts as verified: it IS a past `auth status`, and Log out clears the safe + // (AUTH_STATUS included), so it cannot outlive the identity it describes. + put("loggedIn", probe?.loggedIn ?: stored?.loggedIn) } } + /** + * The first candidate that carries something, or null. Blank counts as absent: the session's own account + * object reports its unknown fields as `""`, and an empty string is a value the frontend would happily + * render as a present-but-empty row. + */ + internal fun firstPresent(vararg candidates: String?): String? = + candidates.firstOrNull { !it.isNullOrBlank() } + /** One row per subagent task: `{ id, desc, type, status, tokens, tools }`; empty array when none. */ private fun subagentsJson(session: ClaudeSession) = buildJsonArray { session.subagentTasks.values.forEach { task -> diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt index d6a0d7c4..11e6c211 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefState.kt @@ -60,7 +60,10 @@ object JcefState { val obj = buildJsonObject { put("turnActive", session.turnActive) put("interrupting", session.interrupting) - put("running", session.isRunning()) + // "Running" for the GUI means the handshake answered, not just that a process exists. The boot + // screen hangs off this, and coming down on a bare spawn showed a chat with empty menus and an + // empty dashboard that populated a beat later. + put("running", session.isRunning() && session.initialized) // "Booting" is a THIRD state, not the absence of `running`: the web app blocks input behind a loading // screen while this is true, and a session that failed to launch must fall out of it (both flags // false) rather than wait forever. @@ -68,6 +71,12 @@ object JcefState { // Resuming reads an existing transcript back and is the slower of the two waits, so the boot screen // labels it differently rather than calling both "Starting" and making the long one look hung. put("resuming", session.isStarting() && session.sessionId != null) + // A FOURTH boot state: the launch found no `claude` binary at all. The web app swaps the spinner + // for the install/path card instead of clearing into an empty tab explained only by a toast. + put("binaryMissing", session.binaryMissing) + // The binary looks unauthenticated (proactive `auth status` probe, or a turn failed on auth). + // Raises the sign-in card — subscription OAuth or API key — wherever it is detected. + put("needsLogin", session.needsLogin) // The live reasoning estimate as a NUMBER, always present (0 when nothing is being reasoned about), // so the readout can render a settled "0" instead of omitting the item. An item that only exists @@ -225,8 +234,12 @@ object JcefState { fun metaJson(session: ClaudeSession): String { // Commands the plugin handles itself (not reported by the binary's slash_commands). + // + // "login" is deliberately NOT here any more: signing in is a BUTTON (the sign-in card, and the + // dashboard's account row), not a command to know about. A typed /login still works — the intercept + // stays as a silent alias, because removing an entry point people have used since 4.0 without any + // notice is how muscle memory gets punished — it is just no longer advertised in the palette. val pluginCommands = mapOf( - "login" to "Sign in to Claude (Anthropic OAuth)", "btw" to "Ask a side question without disturbing the current turn", ) val binaryNames = session.commands.map { it.name }.toSet() @@ -255,6 +268,22 @@ object JcefState { // so the composer must route Ctrl+V through the host (which reads via wl-paste) instead of // trusting the paste event's clipboardData. See JcefChatPanel.PasteClipboard. put("hostClipboard", hostClipboardPreferred) + // Install routes for THIS OS, for the boot card shown when no `claude` binary exists. The host + // decides the list (it knows the OS and the distro); the web app only renders buttons. `display` + // is the exact command a button will run — corporate networks block individual installers, so + // the user must be able to read it, copy it, and take it elsewhere. + put( + "installMethods", + buildJsonArray { + dev.lain.claudejb.process.BinaryInstall.methods().forEach { m -> + addJsonObject { + put("id", m.id) + put("label", m.label) + put("display", m.display) + } + } + }, + ) } return obj.toString() } diff --git a/src/main/resources/jcef/app-composer.js b/src/main/resources/jcef/app-composer.js index d28fda3d..5a0db28a 100644 --- a/src/main/resources/jcef/app-composer.js +++ b/src/main/resources/jcef/app-composer.js @@ -52,6 +52,10 @@ var els = null; // { card, input, send, pills:{provider,model,mode,effort,thinking}, queue, ghost, readout, sendIcon } var lastState = null; // last cc.state payload var announcedBoot = false; // guards the boot screen's one-per-boot screen-reader announcement + var announcedMissing = false; // ditto for the "not found" card, which replaces the loading announcement + var installMethods = []; // from cc.meta: the OS's install routes for the not-found card + var installsBuilt = false; // the card's method rows are built once per methods payload + var installingId = null; // method id whose button shows "Installing…" (cleared on error/card teardown) var commands = []; // from cc.meta var hostClipboard = false; // from cc.meta: native-Wayland toolkit → route paste through the host (wl-paste) var ghostText = ''; // current ghost suggestion (empty field only) @@ -1036,6 +1040,9 @@ ) ); } + // NB no sign-out control here. The readout is a wrapping flex row of metrics, so a button pushed to its + // far end drops onto a second line the moment the numbers fill the width. Log out lives in the tool + // window's title bar (ClaudeToolWindowFactory.SignOutAction) and on the dashboard's account row. ro.removeAttribute('hidden'); if (running && s.thinkingStatus) ro.classList.add('thinking'); else ro.classList.remove('thinking'); @@ -1152,28 +1159,353 @@ var boot = document.getElementById('boot'); var app = document.getElementById('app'); if (!boot) return; - var booting = !s.running && !!s.starting; - boot.hidden = !booting; + // ONE invariant, and everything else follows from it: **the chat is reachable only while the `claude` + // process is running.** Install -> sign in -> loading -> plugin, in that order, and any step backwards + // (the process exits, is restarted, the credential goes away) returns to the matching screen rather + // than leaving a chat on screen that has nothing behind it. + // + // This used to be `starting || binaryMissing`, so every OTHER not-running state — signed out, a dead + // process, a launch that failed — fell through to the chat UI, which then rendered its first frame with + // no session behind it and stayed half-empty. + // Exactly ONE screen at a time, in the order the flow runs: install -> sign in -> loading -> chat. + // `#boot` hosts the install card and the spinner; the sign-in card is its own layer, so the spinner + // must stand down while it is up or it would simply cover it (z-index 60 over 55). + var missing = !s.running && !!s.binaryMissing; + var awaitingAuth = !s.running && !missing && (!!s.needsLogin || authForced); + var booting = !s.running; + var showBoot = missing || (booting && !awaitingAuth); + boot.hidden = !showBoot; + boot.classList.toggle('missing', missing); // Announce the FIRST booting render, not just a transition into it. The screen is already on-screen when // the page loads, so the common case never transitions — and the element's own aria-live never fires // either, because static markup present at load is not a mutation. Once per boot: `announcedBoot` resets // when the screen comes down, so a later relaunch announces again. - if (booting && !announcedBoot) { + if (showBoot && !missing && !announcedBoot) { announcedBoot = true; CC.announce && CC.announce('Loading Claude Code'); } - if (!booting) announcedBoot = false; + if (!showBoot) announcedBoot = false; + // `booting`, not `showBoot`: the chat stays inert for the sign-in screen too. if (app) app.classList.toggle('booting', booting); - if (!booting) return; + var card = document.getElementById('boot-missing'); + if (card) card.hidden = !missing; + if (missing && !announcedMissing) { + announcedMissing = true; + CC.announce && CC.announce('Claude Code was not found. Install options are available.'); + } + if (!missing) { + announcedMissing = false; + installingId = null; // a fresh boot resets any "Installing…" button + setBootError(''); + } + if (!showBoot) return; + if (missing) { + renderInstallMethods(); + return; + } var sub = document.getElementById('boot-sub'); // Distinguish the two waits: a fresh launch versus resuming an existing session, which reads a transcript // back and is the slower of the two. Guessing "Starting" for both made the longer wait look like a hang. if (sub) sub.textContent = s.resuming ? 'Resuming your session' : 'Starting the agent'; } + /** + * The not-found card's method rows: per install route, a button ("Install via X") and under it the exact + * command with a copy affordance ("or copy this command to bash: …"). The command text is the fallback + * that matters: the button runs it in the IDE terminal, and when a corporate network or a missing + * Terminal plugin breaks that, the user copies the same command and runs it anywhere. + */ + function renderInstallMethods() { + var box = document.getElementById('boot-installs'); + if (!box) return; + if (!installsBuilt) { + box.textContent = ''; + installMethods.forEach(function (m) { + var row = document.createElement('div'); + row.className = 'boot-install'; + + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn primary boot-install-btn'; + btn.setAttribute('data-method', m.id); + btn.addEventListener('click', function () { + installingId = m.id; + setBootError(''); + syncInstallButtons(); + CC.announce && CC.announce('Installing Claude Code. Watch the IDE terminal for progress.'); + CC.send({ type: 'installClaude', method: m.id }); + }); + row.appendChild(btn); + + var hint = document.createElement('div'); + hint.className = 'boot-install-hint'; + var hintLabel = document.createElement('span'); + hintLabel.className = 'boot-install-hint-label'; + hintLabel.textContent = 'or copy this command to ' + (m.shell || 'a shell') + ':'; + var code = document.createElement('code'); + code.className = 'boot-install-cmd'; + code.textContent = m.display; + var copy = document.createElement('button'); + copy.type = 'button'; + copy.className = 'btn ghost boot-install-copy'; + copy.textContent = 'Copy'; + copy.setAttribute('aria-label', 'Copy the ' + (m.label || 'install') + ' command'); + copy.addEventListener('click', function (e) { + CC.send({ type: 'copy', text: m.display }); + if (CC.flashCopied) CC.flashCopied(e.currentTarget || copy); + }); + hint.appendChild(hintLabel); + hint.appendChild(code); + hint.appendChild(copy); + row.appendChild(hint); + box.appendChild(row); + }); + installsBuilt = true; + wirePathRow(); + } + syncInstallButtons(); + } + + /** Button labels track the one installing: "Installing…" on it, normal labels (enabled) on the rest, so a + * visibly failed attempt in the terminal can be retried by another route without any reset step. */ + function syncInstallButtons() { + var box = document.getElementById('boot-installs'); + if (!box) return; + var btns = box.querySelectorAll('.boot-install-btn'); + for (var i = 0; i < btns.length; i++) { + var b = btns[i]; + var m = null; + for (var j = 0; j < installMethods.length; j++) { + if (installMethods[j].id === b.getAttribute('data-method')) m = installMethods[j]; + } + if (!m) continue; + var busy = installingId === m.id; + b.textContent = busy ? 'Installing…' : m.label; + b.classList.toggle('installing', busy); + b.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + } + + function wirePathRow() { + var use = document.getElementById('boot-path-use'); + var input = document.getElementById('boot-path'); + if (!use || !input || use.__wired) return; + use.__wired = true; + var submit = function () { + setBootError(''); + CC.send({ type: 'setBinaryPath', path: input.value || '' }); + }; + use.addEventListener('click', submit); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') submit(); + }); + } + + function setBootError(msg) { + var el = document.getElementById('boot-path-err'); + if (el) el.textContent = msg || ''; + } + + /** Host → card: a validation or install-launch failure, verbatim. Clears the "Installing…" state so the + * buttons are usable again — the error IS the end of that attempt. */ + cc.bootPathError = function (msg) { + installingId = null; + syncInstallButtons(); + setBootError(String(msg == null ? '' : msg)); + }; + + // ---- sign-in card --------------------------------------------------------- + + var authForced = false; // raised by cc.showAuth() (fresh install) until dismissed or resolved + var authWired = false; + var announcedAuth = false; + + /** + * The whole card is a function of one step name; every step's markup exists statically in the shell and + * exactly one is visible. Credential inputs are cleared the moment their value is sent — the DOM is a + * debug surface (DevTools, DOM dumps) and a secret must not linger in it. + */ + function setAuthStep(step, url, message) { + var card = document.getElementById('auth-card'); + if (!card) return; + // The host still speaks in flow events ('url' appeared / 'code' requested); both land on the ONE + // combined browser step — the callback path and the paste path are the same screen, with the code + // field merely emphasised once the binary explicitly asks for it. + var wire = step; + if (step === 'url' || step === 'code') step = 'browser'; + var steps = card.querySelectorAll('.auth-step'); + for (var i = 0; i < steps.length; i++) { + steps[i].hidden = steps[i].getAttribute('data-step') !== step; + } + // A restarted flow gets a fresh authorize URL; keeping the previous one would send the user to a + // consent page for an attempt that is already over. + if (step === 'idle' || step === 'waiting') card.__url = null; + if (wire === 'url' && url) card.__url = url; + // Both buttons act on a URL the host has to have handed us; until it does they are inert rather than + // silently no-op, so nobody clicks "Open your browser" and concludes the card is broken. + var hasUrl = !!card.__url; + var open = document.getElementById('auth-url-open'); + var copy = document.getElementById('auth-url-copy'); + if (open) open.disabled = !hasUrl; + if (copy) copy.disabled = !hasUrl; + if (wire === 'code') { + // The binary is now waiting for input — make the optional field the obvious next thing without + // hiding the "the browser can still finish this" framing. + var label = document.getElementById('auth-code-label'); + if (label) label.textContent = 'Paste the authorization code from the browser (or just finish there)'; + var c = document.getElementById('auth-code'); + if (c) c.focus(); + } + if (step === 'error') { + var e = document.getElementById('auth-error'); + if (e) e.textContent = message || 'Sign-in failed. Please try again.'; + } + } + + cc.authState = function (s) { + if (!s || !s.step) return; + setAuthStep(String(s.step), s.url, s.message); + }; + + /** Host → card, proactively (a fresh install has no credentials): raise it without waiting for state. */ + cc.showAuth = function () { + authForced = true; + setAuthStep('idle'); + renderAuth(lastAuthState || {}); + }; + + var lastAuthState = null; + + function renderAuth(s) { + lastAuthState = s; + var card = document.getElementById('auth-card'); + if (!card) return; + // Two gates, both from the same rule (install -> sign in -> loading -> chat): the install card wins, + // because signing in is meaningless without a binary; and a RUNNING session wins over both, so the + // card cannot linger over a live chat once the sign-in it was asking for has happened. + var visible = (!!s.needsLogin || authForced) && !s.binaryMissing && !s.running; + if (!visible && !card.hidden) { + authForced = false; + announcedAuth = false; + setAuthStep('idle'); + } + if (visible && card.hidden) { + wireAuthCard(); + if (!announcedAuth) { + announcedAuth = true; + CC.announce && CC.announce('Sign in to Claude. Options are available.'); + } + } + card.hidden = !visible; + if (!s.needsLogin && !authForced) authForced = false; + } + + function wireAuthCard() { + if (authWired) return; + authWired = true; + // Under the native-Wayland toolkit CEF's clipboard is isolated from the system one, so a plain + // paste into these fields yields nothing. Route it through the host, which reads the real + // clipboard — cc.insertText then lands it in whichever field has focus. + ['auth-key', 'auth-code'].forEach(function (id) { + var el = document.getElementById(id); + if (!el) return; + el.addEventListener('paste', function (e) { + if (!hostClipboard) return; + e.preventDefault(); + CC.send({ type: 'pasteClipboard' }); + }); + }); + var on = function (id, fn) { + var el = document.getElementById(id); + if (el) el.addEventListener('click', fn); + }; + on('auth-sub', function () { + setAuthStep('waiting'); + CC.send({ type: 'loginSubscription' }); + }); + // The organisation route: same OAuth dance, same card, but the consent grants org:create_api_key and the + // binary mints the key itself — nothing to paste, nothing to hand around. + on('auth-console', function () { + setAuthStep('waiting'); + CC.send({ type: 'loginConsole' }); + }); + // Typing a key by hand is the exception now, so it starts collapsed. Kept rather than removed: someone + // holding only a bare API key must not be sent to Settings to get started. + on('auth-key-toggle', function (e) { + var fields = document.getElementById('auth-key-fields'); + if (!fields) return; + var open = fields.hidden; + fields.hidden = !open; + var btn = e.currentTarget; + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + btn.textContent = open ? 'Hide the API key field' : 'Use an API key instead'; + if (open) { + var input = document.getElementById('auth-key'); + if (input) input.focus(); + } + }); + on('auth-key-use', function () { + var input = document.getElementById('auth-key'); + var key = input ? input.value : ''; + if (input) input.value = ''; // never leave a credential sitting in the DOM + CC.send({ type: 'useApiKey', key: key }); + }); + on('auth-code-use', function () { + var input = document.getElementById('auth-code'); + var code = input ? input.value : ''; + if (input) input.value = ''; + setAuthStep('verifying'); + CC.send({ type: 'submitLoginCode', code: code }); + }); + var cancel = function () { + CC.send({ type: 'cancelLogin' }); + setAuthStep('idle'); + }; + on('auth-cancel', cancel); + on('auth-cancel-waiting', cancel); + // Verifying has its own Cancel: a submit that never resolves must not strand the user on a spinner + // with no exit — observed live before the raw-TTY Enter fix, and worth an escape hatch regardless. + on('auth-cancel-verify', cancel); + on('auth-dismiss', function () { + authForced = false; + CC.send({ type: 'dismissAuth' }); + }); + on('auth-retry', function () { + setAuthStep('idle'); + }); + on('auth-url-copy', function (e) { + var card = document.getElementById('auth-card'); + CC.send({ type: 'copy', text: (card && card.__url) || '' }); + if (CC.flashCopied) CC.flashCopied(e.currentTarget); + }); + on('auth-url-open', function () { + var card = document.getElementById('auth-card'); + if (card && card.__url) CC.send({ type: 'open', url: card.__url }); + }); + var keyInput = document.getElementById('auth-key'); + if (keyInput) { + keyInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + var btn = document.getElementById('auth-key-use'); + if (btn) btn.click(); + } + }); + } + var codeInput = document.getElementById('auth-code'); + if (codeInput) { + codeInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + var btn = document.getElementById('auth-code-use'); + if (btn) btn.click(); + } + }); + } + } + function renderState(s) { if (!s) return; announceTurnState(s); + renderAuth(s); renderSendMode(s); renderPills(s); renderQueue(s.queue); @@ -1395,6 +1727,11 @@ cc.meta = function (m) { commands = m && Array.isArray(m.commands) ? m.commands.slice() : []; if (m && typeof m.hostClipboard === 'boolean') hostClipboard = m.hostClipboard; + if (m && Array.isArray(m.installMethods)) { + installMethods = m.installMethods.slice(); + installsBuilt = false; // rebuild the card's buttons if it is (or becomes) visible + renderInstallMethods(); + } // refresh palette list if open var p = CC.els && CC.els.palette; if (p && p.__built && !p.hasAttribute('hidden')) { @@ -1413,8 +1750,23 @@ }; // Host inserts clipboard text into the composer at the caret (Ctrl+V text path on Wayland). + /** + * Host → web paste. Targets the FOCUSED field, not the composer: on a native-Wayland toolkit every + * paste in the web view is routed through the host, so hardcoding the composer meant Ctrl+V did + * nothing at all in the sign-in card's API-key and code inputs — the field looked simply broken. + */ cc.insertText = function (text) { if (text == null) return; + var focused = document.activeElement; + var editable = + focused && + (focused.tagName === 'INPUT' || focused.tagName === 'TEXTAREA') && + !focused.disabled && + !focused.readOnly; + if (editable) { + insertAtCursor(focused, String(text)); + return; + } if (!ensureBuilt() || !els || !els.input) return; els.input.focus(); insertAtCursor(els.input, String(text)); diff --git a/src/main/resources/jcef/app-session.js b/src/main/resources/jcef/app-session.js index f64fe2fd..bc141339 100644 --- a/src/main/resources/jcef/app-session.js +++ b/src/main/resources/jcef/app-session.js @@ -282,6 +282,22 @@ statRow('Plan', acct.plan), statRow('Provider', acct.provider), ]; + // Sign in / Log out, from the VERIFIED auth state only (`loggedIn` comes from the host's + // `auth status` probe). When it is unknown the row is omitted — a button must not claim a state + // nobody checked. Signing in is a button here, not a slash command to know about. + if (acct.loggedIn === true || acct.loggedIn === false) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn account-auth-btn'; + btn.textContent = acct.loggedIn ? 'Log out' : 'Sign in'; + btn.addEventListener('click', function () { + CC.send({ type: acct.loggedIn ? 'logout' : 'loginSubscription' }); + }); + var row = document.createElement('div'); + row.className = 'account-auth-row'; + row.appendChild(btn); + rows.push(row); + } return card('Account', rows); } diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index d7d225f9..b8b2771d 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -1614,6 +1614,13 @@ mark.cc-hit.active { .btn:active { transform: translateY(0) scale(0.98); } +.btn:disabled, +.btn:disabled:hover { + opacity: 0.45; + cursor: default; + transform: none; + filter: none; +} .btn.primary { background: var(--accent); color: #fff; @@ -2476,6 +2483,205 @@ body.reduced-motion .boot-dots::after { user-select: none; } +/* ── "Claude Code was not found" card (the boot overlay's fourth state) ─────── */ +#boot.missing .boot-dots { + display: none; /* nothing is loading — the card asks for a decision, not patience */ +} +#boot-missing { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 560px; + margin-top: 6px; + text-align: left; +} +#boot-missing[hidden] { + display: none; +} +.boot-missing-title { + font-size: 14px; + font-weight: 600; + text-align: center; +} +.boot-missing-sub { + font-size: 12px; + color: var(--dim); + text-align: center; +} +.boot-install { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); +} +.boot-install-btn { + align-self: flex-start; + font-weight: 600; +} +.boot-install-btn.installing { + opacity: 0.75; +} +.boot-install-hint { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.boot-install-hint-label { + font-size: 11px; + color: var(--dim); + white-space: nowrap; +} +.boot-install-cmd { + flex: 1; + min-width: 0; + font-family: var(--mono, monospace); + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; + background: var(--code-bg, rgba(128, 128, 128, 0.12)); + overflow-x: auto; + white-space: nowrap; +} +.boot-path-row { + display: flex; + flex-direction: column; + gap: 4px; +} +.boot-path-label, +.auth-label { + font-size: 11px; + color: var(--dim); +} +.boot-path-controls, +.auth-row { + display: flex; + gap: 6px; + align-items: center; + min-width: 0; +} +.boot-path-controls input, +.auth-row input { + flex: 1; + min-width: 0; + /* Same themed field as the elicitation cards — a bare UA-styled white input reads as foreign + in every dark theme the IDE ships. */ + font-family: var(--font); + font-size: 12.5px; + color: var(--text); + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + outline: none; + transition: + border-color 0.14s, + box-shadow 0.14s; +} +.boot-path-controls input:focus, +.auth-row input:focus { + border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.boot-path-controls input::placeholder, +.auth-row input::placeholder { + color: var(--dim); +} +.boot-path-err, +.auth-error { + font-size: 12px; + color: var(--error, #d66); + min-height: 1em; +} + +/* ── sign-in card ───────────────────────────────────────────────────────────── */ +#auth-card { + position: absolute; + inset: 0; + z-index: 55; /* below #boot (60): no binary beats no login */ + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} +#auth-card[hidden] { + display: none; +} +.auth-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: 440px; + padding: 0 24px; + text-align: center; +} +.auth-title { + font-size: 15px; + font-weight: 600; +} +.auth-step { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} +.auth-step[hidden] { + display: none; +} +.auth-primary { + font-weight: 600; +} +/* The collapsed API-key route. It needs its own display rule (and the [hidden] override that always goes + with one) because .auth-step is a flex column and this is a nested group inside it. */ +#auth-key-fields { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} +#auth-key-fields[hidden] { + display: none; +} +.auth-hint { + font-size: 11px; + color: var(--dim); +} +.auth-divider { + font-size: 11px; + color: var(--dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.auth-quiet { + background: none; + border: none; + color: var(--dim); + text-decoration: underline; + cursor: pointer; + font-size: 12px; +} +.auth-wait { + font-size: 13px; + color: var(--dim); +} +.auth-sub-wait { + font-size: 13px; + font-weight: 600; +} +.auth-url-actions { + gap: 8px; +} +.account-auth-row { + margin-top: 6px; +} +.account-auth-btn { + font-size: 12px; +} + /* Reduced motion — driven by the HOST (body.reduced-motion), deliberately NOT by `@media (prefers-reduced-motion: reduce)`. diff --git a/src/main/resources/jcef/shell.html b/src/main/resources/jcef/shell.html index 68505e9f..50903365 100644 --- a/src/main/resources/jcef/shell.html +++ b/src/main/resources/jcef/shell.html @@ -47,6 +47,138 @@
Loading Claude Code
Starting the agent
+ + + + + + + + diff --git a/src/test/frontend/boot.test.js b/src/test/frontend/boot.test.js index 525aa88e..3397453d 100644 --- a/src/test/frontend/boot.test.js +++ b/src/test/frontend/boot.test.js @@ -34,13 +34,36 @@ describe('boot screen', () => { expect(app().classList.contains('booting')).toBe(false); }); - it('comes down when the launch FAILED — neither starting nor running', () => { - // The regression that matters. A missing binary, a declined trust prompt or a refused remote-mount project - // all end with both flags false. If that did not clear the screen, the tab would stay covered forever with - // no way to reach the notification explaining why. + it('stays up when the launch FAILED — the chat is not reachable without a session', () => { + // The invariant, and it outranks the older reading of this case. A declined trust prompt or a refused + // remote-mount project ends with both flags false; showing the chat then hands the user a composer with + // no process behind it, which is how the dashboard came up half-empty. The explaining notification is an + // IDE toast outside this view, so it is visible either way. win.cc.state({ starting: true, running: false }); win.cc.state({ starting: false, running: false }); + expect(boot().hidden).toBe(false); + }); + + it('a session that dies returns to the loading screen', () => { + win.cc.state({ starting: false, running: true }); + expect(boot().hidden).toBe(true); + win.cc.state({ starting: false, running: false }); + expect(boot().hidden).toBe(false); + }); + + it('the sign-in screen replaces the spinner instead of being covered by it', () => { + // #boot is z-index 60 and the auth card 55, so "both up" means the user stares at a spinner while the + // card they need is underneath it. Exactly one screen at a time. + win.cc.state({ starting: false, running: false, needsLogin: true }); expect(boot().hidden).toBe(true); + expect(win.document.getElementById('auth-card').hidden).toBe(false); + }); + + it('the sign-in card comes down once the session is running', () => { + win.cc.state({ starting: false, running: false, needsLogin: true }); + expect(win.document.getElementById('auth-card').hidden).toBe(false); + win.cc.state({ starting: false, running: true, needsLogin: true }); + expect(win.document.getElementById('auth-card').hidden).toBe(true); }); it('distinguishes resuming from a cold start', () => { @@ -65,3 +88,222 @@ describe('boot screen', () => { expect(region.textContent).toContain('Loading Claude Code'); }); }); + +// The FOURTH boot state: no `claude` binary. The overlay stays up but swaps the spinner for the +// install/path card — a decision to make, not a wait to sit through. +describe('missing-binary card', () => { + let win, sent; + beforeEach(() => { + win = loadFrontend(['app-composer.js'], { vendor: false }); + sent = []; + win.CC.send = (m) => sent.push(m); + win.cc.meta({ + installMethods: [ + { + id: 'sh', + label: 'Install via the official script', + display: 'curl -fsSL https://claude.ai/install.sh | bash', + shell: 'bash', + }, + { id: 'apt', label: 'Install via apt', display: 'sudo apt install claude-code', shell: 'bash' }, + ], + }); + }); + + const card = () => win.document.getElementById('boot-missing'); + const missing = () => win.cc.state({ starting: false, running: false, binaryMissing: true }); + + it('shows the card (and keeps the boot overlay up) when the binary is missing', () => { + missing(); + expect(win.document.getElementById('boot').hidden).toBe(false); + expect(card().hidden).toBe(false); + expect(win.document.getElementById('boot').classList.contains('missing')).toBe(true); + }); + + it('renders one row per method: the button, the exact command, and its Copy', () => { + missing(); + const rows = card().querySelectorAll('.boot-install'); + expect(rows.length).toBe(2); + expect(rows[0].querySelector('.boot-install-btn').textContent).toBe('Install via the official script'); + expect(rows[0].querySelector('.boot-install-cmd').textContent).toContain('install.sh | bash'); + expect(rows[0].querySelector('.boot-install-hint-label').textContent).toContain('bash'); + expect(rows[1].querySelector('.boot-install-copy')).toBeTruthy(); + }); + + it('clicking Install sends the method and flips the button to Installing…', () => { + missing(); + const btn = card().querySelector('.boot-install-btn[data-method="apt"]'); + btn.click(); + expect(sent).toContainEqual({ type: 'installClaude', method: 'apt' }); + expect(btn.textContent).toBe('Installing…'); + expect(btn.getAttribute('aria-busy')).toBe('true'); + }); + + it('Copy sends the exact command without running anything', () => { + missing(); + card().querySelector('.boot-install-copy').click(); + expect(sent).toContainEqual({ type: 'copy', text: 'curl -fsSL https://claude.ai/install.sh | bash' }); + expect(sent.filter((m) => m.type === 'installClaude')).toEqual([]); + }); + + it('the path row submits and a host error resets the Installing state', () => { + missing(); + const input = win.document.getElementById('boot-path'); + input.value = '/opt/claude/claude'; + win.document.getElementById('boot-path-use').click(); + expect(sent).toContainEqual({ type: 'setBinaryPath', path: '/opt/claude/claude' }); + + card().querySelector('.boot-install-btn').click(); + win.cc.bootPathError('That runs, but it isn’t Claude Code.'); + expect(win.document.getElementById('boot-path-err').textContent).toContain('isn’t Claude Code'); + expect(card().querySelector('.boot-install-btn').getAttribute('aria-busy')).toBe('false'); + }); + + it('the card comes down when the binary appears', () => { + missing(); + win.cc.state({ starting: true, running: false, binaryMissing: false }); + expect(card().hidden).toBe(true); + expect(win.document.getElementById('boot-sub').textContent).toBe('Starting the agent'); + }); +}); + +// The sign-in card: raised by needsLogin (or proactively by the host), driven step by step by cc.authState. +describe('sign-in card', () => { + let win, sent; + beforeEach(() => { + win = loadFrontend(['app-composer.js'], { vendor: false }); + sent = []; + win.CC.send = (m) => sent.push(m); + }); + + const card = () => win.document.getElementById('auth-card'); + const step = (name) => card().querySelector('.auth-step[data-step="' + name + '"]'); + + it('appears on needsLogin and on cc.showAuth, and the install card wins over it', () => { + expect(card().hidden).toBe(true); + win.cc.state({ running: false, needsLogin: true }); + expect(card().hidden).toBe(false); + win.cc.state({ running: false, needsLogin: true, binaryMissing: true }); + expect(card().hidden).toBe(true); // signing in is meaningless without a binary + // showAuth() is the post-install nudge, and it lands on a tab with no session yet — a running session + // means the sign-in it would ask for has already happened. + win.cc.state({ running: false, needsLogin: false }); + win.cc.showAuth(); + expect(card().hidden).toBe(false); + }); + + it('subscription click moves to waiting and asks the host to start the flow', () => { + win.cc.state({ running: false, needsLogin: true }); + win.document.getElementById('auth-sub').click(); + expect(sent).toContainEqual({ type: 'loginSubscription' }); + expect(step('waiting').hidden).toBe(false); + expect(step('idle').hidden).toBe(true); + }); + + it('Console click starts the org sign-in — the route that mints its own API key', () => { + win.cc.state({ running: false, needsLogin: true }); + win.document.getElementById('auth-console').click(); + expect(sent).toContainEqual({ type: 'loginConsole' }); + expect(step('waiting').hidden).toBe(false); + }); + + it('the API key field is collapsed behind a disclosure — a button is the primary route now', () => { + win.cc.state({ running: false, needsLogin: true }); + const fields = win.document.getElementById('auth-key-fields'); + const toggle = win.document.getElementById('auth-key-toggle'); + expect(fields.hidden).toBe(true); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + toggle.click(); + expect(fields.hidden).toBe(false); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + // Still fully wired once revealed: the key route must not become second-class by being hidden. + const input = win.document.getElementById('auth-key'); + input.value = 'sk-ant-secret'; + win.document.getElementById('auth-key-use').click(); + expect(sent).toContainEqual({ type: 'useApiKey', key: 'sk-ant-secret' }); + expect(input.value).toBe(''); + + toggle.click(); + expect(fields.hidden).toBe(true); + }); + + it('url and code events both land on the ONE browser step — the code is optional, not a stage', () => { + win.cc.state({ running: false, needsLogin: true }); + win.cc.authState({ step: 'url', url: 'https://claude.ai/oauth/authorize?x=1' }); + expect(step('browser').hidden).toBe(false); + // The URL is never rendered as text — it is 200 characters of query string. Two buttons instead. + expect(win.document.getElementById('auth-url')).toBeNull(); + expect(win.document.getElementById('auth-url-open').disabled).toBe(false); + // The optional code field is already on this screen, before any code event arrives. + expect(win.document.getElementById('auth-code')).toBeTruthy(); + expect(win.document.getElementById('auth-code-label').textContent).toContain('optional'); + win.document.getElementById('auth-url-copy').click(); + expect(sent).toContainEqual({ type: 'copy', text: 'https://claude.ai/oauth/authorize?x=1' }); + win.document.getElementById('auth-url-open').click(); + expect(sent).toContainEqual({ type: 'open', url: 'https://claude.ai/oauth/authorize?x=1' }); + + // The binary asking for the code stays on the SAME step, re-framed — no screen jump. + win.cc.authState({ step: 'code' }); + expect(step('browser').hidden).toBe(false); + expect(win.document.getElementById('auth-code-label').textContent).toContain('authorization code'); + }); + + it('the browser buttons are inert until the host supplies a URL, and a restart drops the old one', () => { + win.cc.state({ running: false, needsLogin: true }); + win.cc.authState({ step: 'waiting' }); + expect(win.document.getElementById('auth-url-open').disabled).toBe(true); + + win.cc.authState({ step: 'url', url: 'https://claude.ai/oauth/authorize?x=1' }); + expect(win.document.getElementById('auth-url-open').disabled).toBe(false); + + // Restarting the flow invalidates that URL — its consent page belongs to an attempt that is over. + win.cc.authState({ step: 'waiting' }); + expect(win.document.getElementById('auth-url-open').disabled).toBe(true); + }); + + it('the code input submits and is CLEARED — a secret must not linger in the DOM', () => { + win.cc.state({ running: false, needsLogin: true }); + win.cc.authState({ step: 'code' }); + const input = win.document.getElementById('auth-code'); + input.value = 'AUTH-CODE-42'; + win.document.getElementById('auth-code-use').click(); + expect(sent).toContainEqual({ type: 'submitLoginCode', code: 'AUTH-CODE-42' }); + expect(input.value).toBe(''); + expect(step('verifying').hidden).toBe(false); + }); + + it('verifying has its own Cancel, so a hung submit is never a dead end', () => { + win.cc.state({ running: false, needsLogin: true }); + win.cc.authState({ step: 'verifying' }); + win.document.getElementById('auth-cancel-verify').click(); + expect(sent).toContainEqual({ type: 'cancelLogin' }); + expect(step('idle').hidden).toBe(false); + }); + + it('the API key input submits and is cleared too', () => { + win.cc.state({ running: false, needsLogin: true }); + const input = win.document.getElementById('auth-key'); + input.value = 'sk-ant-test'; + win.document.getElementById('auth-key-use').click(); + expect(sent).toContainEqual({ type: 'useApiKey', key: 'sk-ant-test' }); + expect(input.value).toBe(''); + }); + + it('dismiss tells the host and an error shows its message with a retry back to idle', () => { + win.cc.state({ running: false, needsLogin: true }); + win.document.getElementById('auth-dismiss').click(); + expect(sent).toContainEqual({ type: 'dismissAuth' }); + + win.cc.authState({ step: 'error', message: 'The sign-in finished but no token was captured' }); + expect(win.document.getElementById('auth-error').textContent).toContain('no token was captured'); + win.document.getElementById('auth-retry').click(); + expect(step('idle').hidden).toBe(false); + }); + + it('comes down when needsLogin clears', () => { + win.cc.state({ running: false, needsLogin: true }); + win.cc.state({ running: true, needsLogin: false }); + expect(card().hidden).toBe(true); + }); +}); diff --git a/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt new file mode 100644 index 00000000..5ce1a12f --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/CredentialsVaultHeadlessTest.kt @@ -0,0 +1,140 @@ +package dev.lain.claudejb.headless + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.process.CredentialsVault +import dev.lain.claudejb.settings.SecretStore +import java.io.File +import java.nio.file.Files + +/** + * Headless: [CredentialsVault] moves the binary's credentials file into the IDE's PasswordSafe and back. + * + * The invariant these pin is the whole point of the vault — **between sessions the credential is in the + * encrypted safe and NOT on the disk**. The full-consent OAuth login writes plaintext JSON to + * `~/.claude/.credentials.json`, readable by anything running as the user; the vault reduces its lifetime + * to the session's. + * + * These run against a TEMPORARY home ([CredentialsVault.homeOverride]), never the real one. That is not + * tidiness: harvest MOVES a credential, so a run that died between taking the file and restoring it would + * sign the developer out of their own CLI for real. + */ +class CredentialsVaultHeadlessTest : BasePlatformTestCase() { + + private val file get() = CredentialsVault.credentialsFile() + private lateinit var home: File + + override fun setUp() { + super.setUp() + home = Files.createTempDirectory("claudejb-home").toFile() + CredentialsVault.homeOverride = home + SecretStore.clear(SecretStore.CREDENTIALS_JSON) + } + + override fun tearDown() { + try { + SecretStore.clear(SecretStore.CREDENTIALS_JSON) + CredentialsVault.homeOverride = null + home.deleteRecursively() + } finally { + super.tearDown() + } + } + + /** A credentials blob whose access token expires [inMs] from now. */ + private fun blob(token: String, inMs: Long) = + """{"claudeAiOauth":{"accessToken":"$token","expiresAt":${System.currentTimeMillis() + inMs}}}""" + + fun `test harvest moves the file into the safe and deletes it`() { + file.parentFile?.mkdirs() + file.writeText("""{"claudeAiOauth":{"accessToken":"secret-token"}}""") + + assertTrue("harvest should report taking something", CredentialsVault.harvest()) + assertFalse("the credential must not remain on disk", file.exists()) + assertEquals( + """{"claudeAiOauth":{"accessToken":"secret-token"}}""", + SecretStore.get(SecretStore.CREDENTIALS_JSON), + ) + } + + fun `test nothing ever writes the credential back to disk`() { + // The invariant, stated as a test: there is no code path that re-creates the plaintext file. The + // credential leaves the safe only through the child process ENVIRONMENT. + file.parentFile?.mkdirs() + file.writeText(blob("live-token", inMs = 6 * 60 * 60 * 1000)) + + assertTrue(CredentialsVault.harvest()) + assertFalse(file.exists()) + // Everything a launch does with the vault, twice over — the file must stay gone. + repeat(2) { + CredentialsVault.envOverlay(emptySet()) + CredentialsVault.hasUsableToken() + CredentialsVault.harvest() + } + assertFalse("the vault must never re-create the plaintext credential", file.exists()) + } + + fun `test harvest is a no-op with no file`() { + assertFalse(CredentialsVault.harvest()) + assertFalse(file.exists()) + } + + fun `test a blank file is left alone rather than overwriting the safe with nothing`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, """{"good":true}""") + file.parentFile?.mkdirs() + file.writeText(" ") + + assertFalse("a half-written file is not a credential", CredentialsVault.harvest()) + assertEquals("""{"good":true}""", SecretStore.get(SecretStore.CREDENTIALS_JSON)) + } + + fun `test clear wipes both the safe entry and the file`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, """{"a":1}""") + // A file left by the terminal CLI (nothing here writes one) must go too: Log out means gone. + file.parentFile?.mkdirs() + file.writeText("""{"a":1}""") + + CredentialsVault.clear() + assertNull(SecretStore.get(SecretStore.CREDENTIALS_JSON)) + assertFalse(file.exists()) + } + + fun `test a live token reaches the binary through the environment, with no file`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, blob("live-token", inMs = 6 * 60 * 60 * 1000)) + + val env = CredentialsVault.envOverlay(emptySet()) + assertEquals(mapOf(SecretStore.OAUTH_TOKEN to "live-token"), env) + // The whole point: the session authenticates with nothing on disk at all. + assertTrue(CredentialsVault.hasUsableToken()) + assertFalse(file.exists()) + } + + fun `test an expired token is not an identity — it cannot be refreshed without the file`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, blob("stale-token", inMs = 60_000)) + + assertTrue("an expiring token must not be handed out", CredentialsVault.envOverlay(emptySet()).isEmpty()) + // Refreshing needs the binary to rewrite its own file, which never happens now. So this counts as + // signed out and the card comes back, instead of a session that fails its first turn. + assertFalse(CredentialsVault.hasUsableToken()) + } + + fun `test an explicit credential outranks the vaulted one`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, blob("vaulted", inMs = 6 * 60 * 60 * 1000)) + + assertTrue(CredentialsVault.envOverlay(setOf(SecretStore.API_KEY)).isEmpty()) + assertTrue(CredentialsVault.envOverlay(setOf(SecretStore.OAUTH_TOKEN)).isEmpty()) + } + + fun `test an unparseable blob is not an identity`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, "not json at all") + + assertTrue(CredentialsVault.envOverlay(emptySet()).isEmpty()) + assertFalse(CredentialsVault.hasUsableToken()) + } + + fun `test the credentials blob is never offered to the child environment`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, """{"a":1}""") + // It is file-shaped, not an env var: leaking it into the environment would put a bearer credential + // in a place nothing reads it from, for no benefit. + assertTrue(SecretStore.envOverlay(emptySet()).isEmpty()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/RollbackManagerHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/RollbackManagerHeadlessTest.kt new file mode 100644 index 00000000..2928a9f9 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/RollbackManagerHeadlessTest.kt @@ -0,0 +1,142 @@ +package dev.lain.claudejb.headless + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.session.DiffLifecycleManager +import dev.lain.claudejb.session.RollbackManager +import dev.lain.claudejb.session.Speaker +import dev.lain.claudejb.session.TranscriptModel +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File +import java.nio.file.Files + +/** + * Headless: [RollbackManager.reviewableEdits] is what the Diff History panel lists, and it is a JOIN of two + * independent sources — the transcript's TOOL rows and the pre-write snapshots [DiffLifecycleManager] holds. + * A row with no snapshot cannot be reverted (there is nothing to restore), so it must be dropped rather than + * offered; a non-file tool must never appear at all. Both are silent failures if they regress: the panel would + * simply show a Restore button that cannot work. + * + * Uses the light fixture's real [project] because the display path is resolved against the project root. + */ +class RollbackManagerHeadlessTest : BasePlatformTestCase() { + + private lateinit var transcript: TranscriptModel + private lateinit var diffs: DiffLifecycleManager + private lateinit var rollback: RollbackManager + + override fun setUp() { + super.setUp() + transcript = TranscriptModel() + diffs = DiffLifecycleManager(project) + rollback = RollbackManager(project, transcript, diffs) { _, _ -> } + } + + private fun tempFile(name: String, content: String): String { + val f = File(Files.createTempDirectory("rollback").toFile(), name) + f.writeText(content) + return f.absolutePath + } + + /** Adds a TOOL row for [tool] and, unless [snapshot] is false, captures its pre-write snapshot. */ + private fun edit(tool: String, id: String, path: String, snapshot: Boolean = true) { + transcript.add(Speaker.TOOL, tool, meta = tool, toolUseId = id) + if (snapshot) { + diffs.captureForReview( + tool, + buildJsonObject { + put("file_path", path) + put("content", "new") + }, + id, + ) + } + } + + fun `test reviewable edits are listed in transcript order`() { + val first = tempFile("a.kt", "a") + val second = tempFile("b.kt", "b") + edit("Write", "toolu_1", first) + edit("Edit", "toolu_2", second) + + val edits = rollback.reviewableEdits() + assertEquals(listOf("toolu_1", "toolu_2"), edits.map { it.toolUseId }) + assertEquals("a", edits[0].snapshot.beforeText) + assertEquals(second, edits[1].snapshot.filePath) + } + + fun `test an edit whose snapshot was never captured is not offered`() { + edit("Write", "toolu_missing", tempFile("c.kt", "c"), snapshot = false) + assertTrue(rollback.reviewableEdits().isEmpty()) + } + + fun `test non-file tools never appear, even with a captured snapshot`() { + val path = tempFile("d.kt", "d") + // Bash is not in DiffPresenter.REVIEWABLE_TOOLS: there is no captured before-state to restore. + edit("Bash", "toolu_bash", path) + assertTrue(rollback.reviewableEdits().isEmpty()) + } + + fun `test reverting an edit restores the captured contents and reseeds the read state`() { + // The light fixture's project root is an in-memory path with nothing behind it, and `FileRollback` + // writes through `LocalFileSystem` — so the root has to exist on disk for a real revert to be possible. + val root = File(project.basePath!!).apply { mkdirs() } + val file = File(root, "reverted.kt") + file.writeText("original\n") + val id = "toolu_revert" + transcript.add(Speaker.TOOL, "Edit", meta = "Edit", toolUseId = id) + val snap = diffs.captureForReview( + "Edit", + buildJsonObject { + put("file_path", file.absolutePath) + put("content", "claude's version\n") + }, + id, + ) + assertNotNull(snap) + // The binary wrote after the snapshot was taken; that is the state the user asks to undo. + file.writeText("claude's version\n") + + // The read-state reseed is what stops the binary's NEXT Edit from validating against the pre-rollback + // contents, so it is part of the contract, not a side effect. + val reseeded = mutableListOf() + val manager = RollbackManager(project, transcript, diffs) { path, _ -> reseeded += path } + + assertTrue(manager.revertEdit(snap!!)) + assertEquals("original\n", file.readText()) + assertEquals(listOf(file.absolutePath), reseeded) + } + + fun `test an edit outside the project root is refused, not written`() { + // The write gate is project-only on purpose: a transcript can name any path, and a rollback must never + // restore stale contents over a file outside the tree the user opened. + val outside = tempFile("outside.kt", "untouched\n") + val id = "toolu_outside" + transcript.add(Speaker.TOOL, "Write", meta = "Write", toolUseId = id) + val snap = diffs.captureForReview( + "Write", + buildJsonObject { + put("file_path", outside) + put("content", "x") + }, + id, + ) + assertFalse(rollback.revertEdit(snap!!)) + assertEquals("untouched\n", File(outside).readText()) + } + + fun `test the display path is project-relative for files inside the root`() { + val inside = File(project.basePath!!, "inside.kt") + val id = "toolu_inside" + transcript.add(Speaker.TOOL, "Write", meta = "Write", toolUseId = id) + diffs.captureForReview( + "Write", + buildJsonObject { + put("file_path", inside.absolutePath) + put("content", "x") + }, + id, + ) + assertEquals("inside.kt", rollback.reviewableEdits().single().displayPath) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/SecretStoreHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/SecretStoreHeadlessTest.kt new file mode 100644 index 00000000..85628515 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/SecretStoreHeadlessTest.kt @@ -0,0 +1,78 @@ +package dev.lain.claudejb.headless + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.settings.SecretStore + +/** + * Headless: [SecretStore] over the test fixture's in-memory PasswordSafe. What is pinned here is the + * CONTRACT the sign-in card and the launch-env overlay rely on — the two entries are mutually exclusive, + * and the overlay never overrides an explicitly-set name. + */ +class SecretStoreHeadlessTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + SecretStore.clearAll() + } finally { + super.tearDown() + } + } + + fun `test set get clear round-trips a stored entry`() { + assertNull(SecretStore.get(SecretStore.OAUTH_TOKEN)) + SecretStore.set(SecretStore.OAUTH_TOKEN, "sk-ant-oat") + assertEquals("sk-ant-oat", SecretStore.get(SecretStore.OAUTH_TOKEN)) + SecretStore.clear(SecretStore.OAUTH_TOKEN) + assertNull(SecretStore.get(SecretStore.OAUTH_TOKEN)) + } + + fun `test setting one credential clears the other — the auth modes are exclusive`() { + SecretStore.set(SecretStore.CREDENTIALS_JSON, """{"claudeAiOauth":{}}""") + SecretStore.set(SecretStore.OAUTH_TOKEN, "sk-ant-oat") + assertNull( + "a stale subscription blob must not silently win over the token just set", + SecretStore.get(SecretStore.CREDENTIALS_JSON), + ) + assertEquals("sk-ant-oat", SecretStore.get(SecretStore.OAUTH_TOKEN)) + } + + fun `test the API key is NOT kept here — it lives in its own provider slot`() { + // ANTHROPIC_API_KEY is an env-var NAME this store knows about, not an entry it holds: the key is + // stored like every other provider's, under providerApiKey:, so the sign-in card and Settings ▸ + // Provider cannot end up disagreeing about which key the binary ran with. + try { + SecretStore.set(SecretStore.API_KEY, "sk-ant-key") + fail("the API key must not be storable here") + } catch (expected: IllegalArgumentException) { + assertTrue(expected.message!!.contains("unknown secret")) + } + } + + fun `test unknown names are refused rather than stored under a typo`() { + // try/catch rather than assertThrows: under the JUnit3 fixture runner the assertThrows lambda + // compiles to a synthetic method whose name starts with "test", which the runner then tries to + // execute as a test of its own and fails on ("Test method isn't public"). + try { + SecretStore.set("ANTHROPIC_APIKEY", "x") + fail("expected IllegalArgumentException for an unknown secret name") + } catch (expected: IllegalArgumentException) { + assertTrue(expected.message!!.contains("unknown secret")) + } + } + + fun `test envOverlay yields stored credentials but never overrides an explicit name`() { + SecretStore.set(SecretStore.OAUTH_TOKEN, "sk-ant-oat") + assertEquals( + mapOf(SecretStore.OAUTH_TOKEN to "sk-ant-oat"), + SecretStore.envOverlay(explicitNames = emptySet()), + ) + // A hand-written Settings/env value keeps winning: the overlay must NOT offer a competing one. + assertTrue(SecretStore.envOverlay(explicitNames = setOf(SecretStore.OAUTH_TOKEN)).isEmpty()) + } + + fun `test clearAll leaves nothing behind for the overlay`() { + SecretStore.set(SecretStore.OAUTH_TOKEN, "sk-ant-oat") + SecretStore.clearAll() + assertTrue(SecretStore.envOverlay(emptySet()).isEmpty()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/integration/FakeClaudeTestBase.kt b/src/test/kotlin/dev/lain/claudejb/integration/FakeClaudeTestBase.kt index 540e4bb6..8cd21e72 100644 --- a/src/test/kotlin/dev/lain/claudejb/integration/FakeClaudeTestBase.kt +++ b/src/test/kotlin/dev/lain/claudejb/integration/FakeClaudeTestBase.kt @@ -48,7 +48,12 @@ abstract class FakeClaudeTestBase : BasePlatformTestCase() { val settings = ClaudeSettings.getInstance(project) settings.state.claudePath = fakeClaude // The plugin forwards these KEY=VALUE lines to the subprocess env (resolveEnv → parseEnv). - settings.state.envVars = "FAKE_FIXTURE=${fixture(fixtureName)}" + // + // ANTHROPIC_API_KEY is here because the session refuses to launch with no identity at all — sign-in + // comes before the loading screen, so an unauthenticated start is a sign-in card, not a process. + // These tests take the same route a user does when they write a credential into Settings by hand; + // the fake binary ignores the value. Deliberately NOT a test-mode bypass in production code. + settings.state.envVars = "FAKE_FIXTURE=${fixture(fixtureName)}\nANTHROPIC_API_KEY=fake-claude-needs-none" settings.state.sourceScript = "" // The session spawns the process in project.basePath; BasePlatformTestCase's temp basePath may not // exist on disk yet (it's a notional path), so materialize it or the spawn fails with diff --git a/src/test/kotlin/dev/lain/claudejb/integration/RateLimitIntegrationTest.kt b/src/test/kotlin/dev/lain/claudejb/integration/RateLimitIntegrationTest.kt index 6d7ff786..b2e7d452 100644 --- a/src/test/kotlin/dev/lain/claudejb/integration/RateLimitIntegrationTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/integration/RateLimitIntegrationTest.kt @@ -2,7 +2,8 @@ package dev.lain.claudejb.integration /** * A `rate_limit_event` on the stream is decoded into [RateLimitInfo] and exposed via [ClaudeSession.rateLimit] - * (drives the quota bar). Fixture reports a five-hour window at 92.5% utilization with a warning status. + * (drives the quota bar). The fixture reports a five-hour window at `utilization: 0.925` — the FRACTION the + * binary really sends (captured live on 2.1.223), not a 0..100 percentage. */ class RateLimitIntegrationTest : FakeClaudeTestBase() { @@ -17,6 +18,6 @@ class RateLimitIntegrationTest : FakeClaudeTestBase() { assertTrue("isWarning", rl.isWarning) assertEquals("five_hour", rl.rateLimitType) assertEquals("5h", rl.windowLabel()) - assertEquals(92, rl.utilizationPercent()) + assertEquals(93, rl.utilizationPercent()) // 0.925 -> 92.5% -> rounds to 93 } } diff --git a/src/test/kotlin/dev/lain/claudejb/process/BinaryInstallTest.kt b/src/test/kotlin/dev/lain/claudejb/process/BinaryInstallTest.kt new file mode 100644 index 00000000..6d86d3b4 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/process/BinaryInstallTest.kt @@ -0,0 +1,108 @@ +package dev.lain.claudejb.process + +import com.intellij.openapi.util.SystemInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import java.io.File +import kotlin.io.path.createTempDirectory + +/** + * [BinaryInstall]: the install-method catalogue and the "is this really the claude binary?" validation. + * + * The exec-based validation tests run a real process (a tiny shell script standing in for the binary), so + * they are POSIX-only — Windows is excluded with `assumeTrue`, the same convention as the two established + * Windows-only skips elsewhere in the suite. + */ +class BinaryInstallTest { + + // ── methods() ──────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `methods exist for this OS, with unique ids and complete fields`() { + val methods = BinaryInstall.methods() + assertTrue("at least one install route per OS", methods.isNotEmpty()) + assertEquals("ids must be unique", methods.size, methods.map { it.id }.toSet().size) + methods.forEach { m -> + assertTrue("label: ${m.id}", m.label.isNotBlank()) + assertTrue("display: ${m.id}", m.display.isNotBlank()) + assertTrue("argv: ${m.id}", m.argv.isNotEmpty()) + assertTrue("shell: ${m.id}", m.shell.isNotBlank()) + } + } + + @Test + fun `method resolves by id and unknown ids yield null`() { + val first = BinaryInstall.methods().first() + assertEquals(first, BinaryInstall.method(first.id)) + assertEquals(null, BinaryInstall.method("no-such-method")) + } + + // ── validate() ─────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `blank and nonexistent paths are invalid`() { + assertTrue(BinaryInstall.validate("") is BinaryInstall.Validation.Invalid) + assertTrue(BinaryInstall.validate(" ") is BinaryInstall.Validation.Invalid) + assertTrue(BinaryInstall.validate("/definitely/not/here/claude") is BinaryInstall.Validation.Invalid) + } + + @Test + fun `a directory without a claude executable inside is invalid`() { + val dir = createTempDirectory("bi-empty").toFile() + try { + assertTrue(BinaryInstall.validate(dir.absolutePath) is BinaryInstall.Validation.Invalid) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun `an executable that is not claude is rejected by the version probe`() { + assumeTrue(!SystemInfo.isWindows) + val fake = script("#!/bin/sh\necho \"totally-not-claude 1.0\"\n") + try { + val verdict = BinaryInstall.validate(fake.absolutePath) + assertTrue("expected Invalid, got $verdict", verdict is BinaryInstall.Validation.Invalid) + } finally { + fake.parentFile.deleteRecursively() + } + } + + @Test + fun `a binary that identifies as Claude Code passes, from a file or its directory`() { + assumeTrue(!SystemInfo.isWindows) + val fake = script("#!/bin/sh\necho \"9.9.9 (Claude Code)\"\n", name = "claude") + try { + val byFile = BinaryInstall.validate(fake.absolutePath) + assertTrue("by file: $byFile", byFile is BinaryInstall.Validation.Ok) + assertTrue((byFile as BinaryInstall.Validation.Ok).version.contains("Claude Code")) + // Answering with the DIRECTORY is the question users can actually answer. + val byDir = BinaryInstall.validate(fake.parentFile.absolutePath) + assertTrue("by dir: $byDir", byDir is BinaryInstall.Validation.Ok) + } finally { + fake.parentFile.deleteRecursively() + } + } + + @Test + fun `a non-executable file is invalid before any probe runs`() { + assumeTrue(!SystemInfo.isWindows) + val dir = createTempDirectory("bi-noexec").toFile() + val file = File(dir, "claude").apply { writeText("#!/bin/sh\necho hi\n") } + try { + assertTrue(BinaryInstall.validate(file.absolutePath) is BinaryInstall.Validation.Invalid) + } finally { + dir.deleteRecursively() + } + } + + private fun script(body: String, name: String = "fake-bin"): File { + val dir = createTempDirectory("bi-exec").toFile() + return File(dir, name).apply { + writeText(body) + setExecutable(true) + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/process/LoginOutputParserTest.kt b/src/test/kotlin/dev/lain/claudejb/process/LoginOutputParserTest.kt index 1c9e5529..17f8e982 100644 --- a/src/test/kotlin/dev/lain/claudejb/process/LoginOutputParserTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/process/LoginOutputParserTest.kt @@ -60,9 +60,59 @@ class LoginOutputParserTest { ) } + // ── the frames the login TUI renders before exiting ────────────────────────────────────────────────── + + /** The frame the binary renders on success under a PTY, right before it exits 0. */ + private val successScreen = + "$esc[2mLogged in as dev@example.com$esc[0m\r\n" + + "$esc[32mLogin successful. Press $esc[1mEnter$esc[0m$esc[32m to continue…$esc[0m" + + @Test + fun `surfaces the binary's own wording for a failed OAuth exchange`() { + val screen = "$esc[31mOAuth error: invalid_grant$esc[0m\r\nPress Enter to retry." + assertTrue(LoginOutputParser.looksLikeFailure(screen)) + assertEquals("OAuth error: invalid_grant", LoginOutputParser.resultMessage(screen, success = false)) + } + + @Test + fun `result message drops the terminal-only keypress instruction`() { + assertEquals( + "Login successful.", + LoginOutputParser.resultMessage(successScreen, success = true), + ) + } + + @Test + fun `redactSecrets strips ANSI and masks tokens`() { + val token = "sk-ant-oat01-" + "c".repeat(40) + val out = LoginOutputParser.redactSecrets("$esc[32mtoken: $token$esc[0m") + assertFalse(out.contains(token)) + assertFalse(out.contains(esc)) + assertTrue(out.contains("sk-ant-…")) + } + @Test fun `result falls back to generic wording when no marker line is present`() { assertEquals("You're signed in.", LoginOutputParser.resultMessage("(some unrelated frame)", success = true)) assertEquals("Login failed. Please try again.", LoginOutputParser.resultMessage("(noise)", success = false)) } + + // ── setup-token ────────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `extracts the setup token, taking the LAST match past placeholder text`() { + val token = "sk-ant-oat01-" + "a".repeat(40) + val out = "$esc[2mExample: sk-ant-oat01-xxxxxxxxxxxxxxxxxxxx$esc[0m\nYour token:\n$token\n" + assertEquals(token, LoginOutputParser.extractSetupToken(out)) + assertNull(LoginOutputParser.extractSetupToken("no token here")) + // Too short to be real — placeholder-sized fragments must not be captured as credentials. + assertNull(LoginOutputParser.extractSetupToken("sk-ant-short")) + } + + @Test + fun `result messages never carry a token`() { + val token = "sk-ant-oat01-" + "b".repeat(40) + val msg = LoginOutputParser.resultMessage("Login successful! Token: $token", success = true) + assertFalse(msg.contains(token), "a secret leaked into a user-facing message") + } } diff --git a/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt b/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt new file mode 100644 index 00000000..2c384b92 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt @@ -0,0 +1,122 @@ +package dev.lain.claudejb.process + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +/** + * **This plugin deletes exactly one file: `~/.claude/.credentials.json`, when it harvests it into the safe. + * Nothing else. No exceptions, no "temporary" directory, no cleanup path.** + * + * THE INCIDENT THIS PINS (5.0.0, `ce49635`). The auth work built a per-session `CLAUDE_CONFIG_DIR` so the + * binary could read its own credentials file without that file living in `~/.claude`, and — so the relocation + * would not hide the user's real configuration — symlinked every entry of `~/.claude` into it. The teardown + * then wiped the temp dir with one line: + * + * ```kotlin + * runCatching { dir.deleteRecursively() } // RuntimeConfigDir.collect + * ``` + * + * `File.deleteRecursively()` walks with `FileTreeWalk`, and `FileTreeWalk` FOLLOWS symlinks to directories + * (`isDirectory` is true, `listFiles()` returns their contents). So every `stop()` — a logout, a closed tab, + * a closed IDE — descended through those links and emptied the real directories: `projects` (every past + * conversation, in every project), `skills`, `sessions`, `cache`, `ide`, and the user's own `backup-*` dirs. + * It destroyed a real user's history, and there was no undo because there was no copy. + * + * Both halves were individually defensible and the composition was a `rm -rf` of someone's data. That is + * precisely the kind of defect a review does not catch and a type system cannot see, so the rule is not + * "delete carefully" — it is a source contract with no judgement in it: + * + * 1. **No recursive deletion anywhere**, including in the allowed file. There is not one legitimate use in + * this codebase, which is what makes the ban absolute and therefore checkable. + * 2. **No deletion API outside [CredentialsVault]**, which deletes the one plaintext credential it just + * moved into the IDE safe — the whole reason that class exists. + * + * If a future change genuinely needs to remove something, it does not edit the allowlist: it explains itself + * to the user first, because the last time this was decided unilaterally it cost them their conversations. + */ +class NoFileDeletionContractTest { + + /** Recursive-delete APIs. Banned outright — target file included. */ + private val recursive = listOf( + "deleteRecursively", + "FileUtil.delete(", + "FileUtils.deleteDirectory", + "FileUtils.forceDelete", + "walkFileTree", + ) + + /** Single-file deletion APIs. Allowed ONLY in [ALLOWED]. */ + private val single = listOf( + ".delete()", + "deleteIfExists", + "Files.delete(", + "deleteOnExit", + ) + + /** The one file permitted to delete, and the one thing it is permitted to delete. */ + private companion object { + const val ALLOWED = "CredentialsVault.kt" + } + + @Test + fun `no source file deletes recursively`() { + val offenders = ktFiles().flatMap { file -> + hits(file, recursive).map { "${file.name}:${it.first}: ${it.second}" } + } + assertTrue(offenders.isEmpty()) { + "Recursive deletion is banned in this codebase — it emptied a user's whole ~/.claude once, " + + "through symlinks (see this test's KDoc). Remove it; do not \"fix\" it.\n" + + offenders.joinToString("\n") + } + } + + @Test + fun `only CredentialsVault deletes a file`() { + val offenders = ktFiles().filter { it.name != ALLOWED }.flatMap { file -> + hits(file, single).map { "${file.name}:${it.first}: ${it.second}" } + } + assertTrue(offenders.isEmpty()) { + "Only $ALLOWED may delete a file, and only the plaintext credentials file it harvested. " + + "Everything else on the user's disk — conversations above all — is theirs.\n" + + offenders.joinToString("\n") + } + } + + @Test + fun `the one permitted deletion targets the credentials file and nothing else`() { + val vault = ktFiles().first { it.name == ALLOWED } + // Every deleting line in the vault must act on a `file` resolved from credentialsFile(). Pinned by + // reading the receiver rather than trusting the filename: the allowlist is per-FILE, so without this + // the vault would be a hole big enough to delete anything from. + val bad = hits(vault, single).filterNot { (_, line) -> Regex("""\bfile\.delete\(\)""").containsMatchIn(line) } + assertTrue(bad.isEmpty()) { + "$ALLOWED may only delete the harvested credentials file (`file.delete()`, where `file` is " + + "credentialsFile()).\n" + bad.joinToString("\n") { "${vault.name}:${it.first}: ${it.second}" } + } + assertTrue(vault.readText().contains("fun credentialsFile()")) { + "$ALLOWED no longer resolves credentialsFile() — this contract is checking the wrong thing." + } + } + + /** + * Matching lines as (1-based line number, trimmed text), skipping comments: these APIs are NAMED in the + * KDoc that explains why they are banned, and a contract that fails on its own explanation is a contract + * people delete. + */ + private fun hits(file: File, needles: List): List> = + file.readLines().mapIndexedNotNull { index, raw -> + val line = raw.trim() + if (line.startsWith("*") || line.startsWith("//") || line.startsWith("/*")) return@mapIndexedNotNull null + if (needles.any { it in line }) index + 1 to line else null + } + + private fun ktFiles(): List = + sourceRoot().walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + + /** Resolves `src/main/kotlin` whether the test runs from the module dir or the repo root. */ + private fun sourceRoot(): File = + sequenceOf(File("src/main/kotlin"), File("../src/main/kotlin")) + .firstOrNull { it.isDirectory } + ?: error("could not locate src/main/kotlin from ${File("").absolutePath}") +} diff --git a/src/test/kotlin/dev/lain/claudejb/process/TerminalLauncherTest.kt b/src/test/kotlin/dev/lain/claudejb/process/TerminalLauncherTest.kt index 5fb5a868..4bd0bb90 100644 --- a/src/test/kotlin/dev/lain/claudejb/process/TerminalLauncherTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/process/TerminalLauncherTest.kt @@ -45,19 +45,19 @@ class TerminalLauncherTest { assertTrue(cmd.startsWith("& \"")) } - // ── argv form: what actually gets handed to the terminal (no shell parses it) ──────────────────────────── + // ── the sign-in mode travels with the command ─────────────────────────────────────────────────────────── @Test - fun `loginArgv is the binary plus the auth login subcommand, unquoted and unsplit`() { - // Passed as the tab's shellCommand, so a path with spaces must stay ONE element — no quoting, no escaping, - // no PowerShell call operator: there is no shell in the middle to misparse it. + fun `the subcommand comes from the caller, so Console and SSO are not silently turned into a plain login`() { + // A last-resort notice that told a Console user to run the SUBSCRIPTION login would send them through + // the wrong OAuth consent — a different account type, not a cosmetic difference. assertEquals( - listOf("/Applications/My Tools/claude", "auth", "login"), - TerminalLauncher.loginArgv("/Applications/My Tools/claude"), + "\"/usr/bin/claude\" auth login --console", + TerminalLauncher.loginCommand("/usr/bin/claude", listOf("auth", "login", "--console"), isWindows = false), ) assertEquals( - listOf("C:\\Program Files\\claude\\claude.exe", "auth", "login"), - TerminalLauncher.loginArgv("C:\\Program Files\\claude\\claude.exe"), + "& \"C:\\bin\\claude.exe\" auth login --sso", + TerminalLauncher.loginCommand("C:\\bin\\claude.exe", listOf("auth", "login", "--sso"), isWindows = true), ) } } diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt index dbcd5eb8..4070a47c 100644 --- a/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserTest.kt @@ -335,7 +335,8 @@ class ProtocolParserTest { @Test fun `rate_limit_event becomes RateLimit`() { - val line = """{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","rateLimitType":"five_hour","utilization":92}}""" + // 0.92, not 92: the event's scale is a fraction — see RateLimitInfo.utilizationPercent. + val line = """{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","rateLimitType":"five_hour","utilization":0.92}}""" val event = parseOne(line) assertTrue(event.info.isWarning) assertEquals("5h", event.info.windowLabel()) diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/RateLimitInfoTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/RateLimitInfoTest.kt index df7debf6..2b55ad60 100644 --- a/src/test/kotlin/dev/lain/claudejb/protocol/RateLimitInfoTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/protocol/RateLimitInfoTest.kt @@ -7,27 +7,40 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test /** - * Pure logic of [RateLimitInfo]. The binary reports quota utilization on two different scales (0..100 and, - * near the limit, 0..1) and varying window/status strings; the UI quota bar depends on this normalization, - * so these tests pin the contract independently of the wire decoding (which ProtocolParserTest covers). + * Pure logic of [RateLimitInfo]. The UI quota bar depends on this normalization, so the contract is pinned + * here independently of the wire decoding (which ProtocolParserTest covers). + * + * THE SCALE IS A 0..1 FRACTION, and it is NOT the same as `get_usage`'s. Both previous versions of this + * file were wrong in opposite directions, which is why the source is a live capture rather than a reading + * of the types — `claude` 2.1.223, with claude.ai showing 92% of the weekly window spent: + * + * ``` + * {"status":"allowed_warning","rateLimitType":"seven_day","utilization":0.92,"surpassedThreshold":0.75} + * ``` + * + * `sdk.d.ts` documents "Percentage of the window used, 0-100" only on the `get_usage` windows; + * `SDKRateLimitInfo.utilization` says nothing, and assuming it matched turned a 92% window into a 1% bar. */ class RateLimitInfoTest { // --- utilizationPercent --- @Test - fun `utilization on 0 to 100 scale is passed through`() { - assertEquals(92, RateLimitInfo(utilization = 92.0).utilizationPercent()) + fun `the live capture — 0_92 is 92 percent`() { + assertEquals(92, RateLimitInfo(utilization = 0.92).utilizationPercent()) } @Test - fun `utilization on 0 to 1 scale is multiplied by 100`() { - assertEquals(50, RateLimitInfo(utilization = 0.5).utilizationPercent()) + fun `a full window is 1_0, not 100`() { + assertEquals(100, RateLimitInfo(utilization = 1.0).utilizationPercent()) } @Test - fun `utilization exactly 1 is treated as the fractional scale`() { - assertEquals(100, RateLimitInfo(utilization = 1.0).utilizationPercent()) + fun `a freshly reset window reads low, not full`() { + // The regression this replaces: 0.01 read on the 0..100 scale rounded to 0 and, before that, a + // "<= 1.0 means fraction" guess turned a genuine 1 into 100. + assertEquals(1, RateLimitInfo(utilization = 0.01).utilizationPercent()) + assertEquals(6, RateLimitInfo(utilization = 0.06).utilizationPercent()) } @Test @@ -37,8 +50,8 @@ class RateLimitInfoTest { @Test fun `utilization is clamped to 0 and 100`() { - assertEquals(100, RateLimitInfo(utilization = 150.0).utilizationPercent()) - assertEquals(0, RateLimitInfo(utilization = -5.0).utilizationPercent()) + assertEquals(100, RateLimitInfo(utilization = 1.5).utilizationPercent()) + assertEquals(0, RateLimitInfo(utilization = -0.5).utilizationPercent()) } // --- status flags --- diff --git a/src/test/kotlin/dev/lain/claudejb/session/LoginModeTest.kt b/src/test/kotlin/dev/lain/claudejb/session/LoginModeTest.kt new file mode 100644 index 00000000..7eadd389 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/LoginModeTest.kt @@ -0,0 +1,48 @@ +package dev.lain.claudejb.session + +import dev.lain.claudejb.process.TerminalLauncher +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The sign-in routes are argv, and the argv IS the route: `--console` bills API usage against an organization + * and its consent carries `org:create_api_key`, while the plain form signs in to a personal subscription. A + * silent mix-up here signs the user into the wrong thing and looks like a working login, so the flags are + * pinned rather than trusted — including through [TerminalLauncher.loginCommand], the last-resort text the + * user is told to run by hand, which must name the same subcommand the card would have run. + */ +class LoginModeTest { + + @Test + fun `each mode carries the auth login subcommand plus its own flag`() { + assertEquals(listOf("auth", "login"), LoginCoordinator.Mode.SUBSCRIPTION.args) + assertEquals(listOf("auth", "login", "--console"), LoginCoordinator.Mode.CONSOLE.args) + assertEquals(listOf("auth", "login", "--sso"), LoginCoordinator.Mode.SSO.args) + } + + @Test + fun `no mode uses a bare login, which the binary would treat as a prompt`() { + LoginCoordinator.Mode.entries.forEach { mode -> + assertEquals("${mode.name} must go through the auth subcommand", "auth", mode.args.first()) + assertEquals("${mode.name} must call login", "login", mode.args[1]) + } + } + + @Test + fun `the manual fallback command quotes the binary and keeps the mode's flags`() { + val posix = TerminalLauncher.loginCommand( + "/home/u/my tools/claude", + LoginCoordinator.Mode.CONSOLE.args, + isWindows = false, + ) + assertEquals("\"/home/u/my tools/claude\" auth login --console", posix) + + // PowerShell needs the call operator to execute a quoted path; POSIX must NOT get it (it would background). + val windows = TerminalLauncher.loginCommand( + "C:\\Program Files\\claude.exe", + LoginCoordinator.Mode.SSO.args, + isWindows = true, + ) + assertEquals("& \"C:\\Program Files\\claude.exe\" auth login --sso", windows) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/session/SessionStoreTest.kt b/src/test/kotlin/dev/lain/claudejb/session/SessionStoreTest.kt index 73dff641..31bf6845 100644 --- a/src/test/kotlin/dev/lain/claudejb/session/SessionStoreTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/session/SessionStoreTest.kt @@ -43,51 +43,27 @@ class SessionStoreTest { } @Test - fun `delete removes only the targeted UUID-named transcript and is confined to the projects tree`() { + fun `locate finds the transcript without removing anything`() { + // What used to be the `delete` tests. The capability is gone (see the class KDoc): the store reads, + // and this pins that a lookup leaves the whole tree exactly as it found it. val home = Files.createTempDirectory("claudejb-home") val originalHome = System.getProperty("user.home") try { - // Build ~/.claude/projects// with a session file and an unrelated sibling that must survive. val projectDir = home.resolve(".claude").resolve("projects").resolve("-tmp-proj") Files.createDirectories(projectDir) val id = "11111111-2222-3333-4444-555555555555" val target = projectDir.resolve("$id.jsonl").also { Files.writeString(it, "{}") } val sibling = projectDir.resolve("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl").also { Files.writeString(it, "{}") } - // A would-be victim outside the projects tree: a traversal must never reach it. - val outside = home.resolve("secret.jsonl").also { Files.writeString(it, "do not delete") } - // Point SessionStore at the temp home, then exercise delete. System.setProperty("user.home", home.toString()) - // Traversal / non-UUID ids are rejected before any FS access — nothing is deleted. - assertFalse(SessionStore.delete("../../secret"), "traversal id must be rejected") - assertFalse(SessionStore.delete("$id.jsonl"), "id carrying a dot must be rejected") - assertTrue(Files.exists(outside), "file outside the projects tree must survive") - - // A genuine delete removes exactly the target. - assertTrue(SessionStore.delete(id), "valid UUID delete should succeed") - assertFalse(Files.exists(target), "targeted transcript must be gone") - assertTrue(Files.exists(sibling), "unrelated session must survive") - - // Deleting again (now absent) is a no-op false. - assertFalse(SessionStore.delete(id), "deleting an absent session returns false") + assertEquals(target, SessionStore.locate(id), "the targeted transcript must be found") + assertTrue(SessionStore.exists(id)) + assertTrue(Files.exists(target), "locate must not remove the file it resolved") + assertTrue(Files.exists(sibling), "nor any sibling") } finally { System.setProperty("user.home", originalHome) Files.walk(home).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } } } - - @Test - fun `delete is a no-op when no projects tree exists`() { - // Sanity: with no projects tree present, even a well-formed id deletes nothing (locate short-circuits). - val home = Files.createTempDirectory("claudejb-empty-home") - val originalHome = System.getProperty("user.home") - try { - System.setProperty("user.home", home.toString()) - assertFalse(SessionStore.delete("11111111-2222-3333-4444-555555555555")) - } finally { - System.setProperty("user.home", originalHome) - Files.deleteIfExists(home) - } - } } diff --git a/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt b/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt new file mode 100644 index 00000000..58826933 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt @@ -0,0 +1,191 @@ +package dev.lain.claudejb.session + +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 +import java.nio.file.Files + +/** + * What a RESTORED conversation looks like, pinned against the binary's own JSONL. + * + * This is the read path behind "Open Previous Session…" and the startup restore, and it is the only + * reconstruction the plugin has: the transcripts are the binary's files and the plugin never duplicates them. + * A silent regression here does not throw — it renders a conversation that is subtly not the one that + * happened (an output with no call, a command card downgraded to plain text, a prompt attributed to the + * model), which is exactly the class of defect that survives a manual glance. + */ +class SessionTranscriptReaderParseTest { + + private fun user(text: String) = + """{"type":"user","message":{"role":"user","content":"$text"}}""" + + private fun assistantText(text: String) = + """{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"$text"}]}}""" + + @Test + fun `a string prompt and an array prompt both come back as USER text`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + user("plain string content"), + """{"type":"user","message":{"role":"user","content":[{"type":"text","text":"array block"}]}}""", + // Blank text is not a turn: the binary emits these around tool plumbing. + """{"type":"user","message":{"role":"user","content":[{"type":"text","text":" "}]}}""", + ), + ) + assertEquals(listOf("plain string content", "array block"), entries.map { it.text }) + assertTrue(entries.all { it.speaker == "USER" }) + } + + @Test + fun `thinking and text blocks keep their distinct speakers`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + """{"type":"assistant","message":{"content":[ + {"type":"thinking","thinking":"weighing it up"}, + {"type":"text","text":"the answer"}]}}""".replace("\n", ""), + ), + ) + assertEquals(listOf("THINKING" to "weighing it up", "ASSISTANT" to "the answer"), entries.map { it.speaker to it.text }) + } + + @Test + fun `a tool_result is attributed to TOOL_OUTPUT and carries its error flag`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + """{"type":"user","message":{"content":[ + {"type":"tool_result","tool_use_id":"t1","content":"it worked"}]}}""".replace("\n", ""), + """{"type":"user","message":{"content":[ + {"type":"tool_result","tool_use_id":"t2","is_error":true,"content":[ + {"type":"text","text":"line one"},{"type":"text","text":"line two"}]}]}}""".replace("\n", ""), + ), + ) + assertEquals(listOf("TOOL_OUTPUT", "TOOL_OUTPUT"), entries.map { it.speaker }) + assertEquals("it worked", entries[0].text) + assertNull(entries[0].meta) + // An array content is concatenated, and the error tag is the same one the live path sets. + assertEquals("line one\nline two", entries[1].text) + assertEquals("error", entries[1].meta) + } + + @Test + fun `a command's output is tagged command, even though its call is on an earlier line`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + """{"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"c1","name":"Bash","input":{"command":"ls -la"}}]}}""".replace("\n", ""), + """{"type":"user","message":{"content":[ + {"type":"tool_result","tool_use_id":"c1","content":"total 0"}]}}""".replace("\n", ""), + """{"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"/tmp/x"}}]}}""".replace("\n", ""), + """{"type":"user","message":{"content":[ + {"type":"tool_result","tool_use_id":"r1","content":"file body"}]}}""".replace("\n", ""), + ), + ) + val byId = entries.filter { it.speaker == "TOOL_OUTPUT" }.associateBy { it.toolUseId } + // The command tag is what makes a reloaded card render the copyable code block a live one does. + assertEquals("command", byId["c1"]?.meta) + assertNull(byId["r1"]?.meta, "a non-command tool's output must not be tagged") + assertEquals("ls -la", entries.first { it.toolUseId == "c1" && it.speaker == "TOOL" }.commandText) + } + + @Test + fun `an error on a command's output keeps both tags`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + """{"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"c1","name":"Bash","input":{"command":"false"}}]}}""".replace("\n", ""), + """{"type":"user","message":{"content":[ + {"type":"tool_result","tool_use_id":"c1","is_error":true,"content":"boom"}]}}""".replace("\n", ""), + ), + ) + assertEquals("command error", entries.first { it.speaker == "TOOL_OUTPUT" }.meta) + } + + @Test + fun `the tail cap drops an output whose call fell outside the window`() { + val lines = listOf( + """{"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/a"}}]}}""".replace("\n", ""), + """{"type":"user","message":{"content":[ + {"type":"tool_result","tool_use_id":"t1","content":"body"}]}}""".replace("\n", ""), + user("still here"), + ) + // Window of 2 would be [TOOL_OUTPUT, USER]; the orphan output goes, so only the prompt survives. + val capped = SessionTranscriptReader.parseEntries(lines, maxEntries = 2) + assertEquals(listOf("USER"), capped.map { it.speaker }) + // A non-positive cap is "no cap", not "nothing". + assertEquals(3, SessionTranscriptReader.parseEntries(lines, maxEntries = 0).size) + assertEquals(3, SessionTranscriptReader.parseEntries(lines, maxEntries = null).size) + } + + @Test + fun `corrupt, blank and unknown lines are skipped rather than fatal`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + "", + " ", + "not json at all", + """{"type":"ai-title","title":"whatever"}""", + """{"type":"summary","summary":"skip me"}""", + user("the only turn"), + ), + ) + assertEquals(listOf("the only turn"), entries.map { it.text }) + } + + @Test + fun `metadata takes the first prompt, branch and timestamp it finds`() { + val meta = SessionTranscriptReader.parseMetadata( + listOf( + "garbage", + """{"type":"user","gitBranch":"feature/x","timestamp":"2026-08-06T10:00:00Z", + "message":{"content":[{"type":"tool_result","tool_use_id":"t","content":"not a prompt"}]}}""" + .replace("\n", ""), + """{"type":"user","gitBranch":"ignored-later","timestamp":"2026-08-06T11:00:00Z", + "message":{"content":[{"type":"text","text":"the real first prompt"}]}}""".replace("\n", ""), + ), + ) + // A tool_result is not a prompt, so the first REAL user text wins — and first-wins holds for the rest. + assertEquals("the real first prompt", meta.firstPrompt) + assertEquals("feature/x", meta.gitBranch) + assertEquals("2026-08-06T10:00:00Z", meta.createdAt) + } + + @Test + fun `metadata is all-null for a transcript that carries none of it`() { + val meta = SessionTranscriptReader.parseMetadata(listOf(assistantText("model only"), "{}")) + assertNull(meta.firstPrompt) + assertNull(meta.gitBranch) + assertNull(meta.createdAt) + } + + @Test + fun `the store lists a project's transcripts newest-first and tolerates an absent tree`() { + val home = Files.createTempDirectory("claudejb-list-home") + val originalHome = System.getProperty("user.home") + try { + System.setProperty("user.home", home.toString()) + assertNull(SessionStore.projectDir("/tmp/proj"), "no tree yet → no project dir") + assertTrue(SessionStore.listFiles("/tmp/proj").isEmpty(), "no tree yet → no files") + assertTrue(SessionStore.listFiles("").isEmpty(), "a blank base path resolves nothing") + + val dir = home.resolve(".claude").resolve("projects").resolve(SessionStore.encodePath("/tmp/proj")) + Files.createDirectories(dir) + val older = dir.resolve("11111111-1111-1111-1111-111111111111.jsonl") + val newer = dir.resolve("22222222-2222-2222-2222-222222222222.jsonl") + Files.writeString(older, "{}") + Files.writeString(newer, "{}") + Files.setLastModifiedTime(older, java.nio.file.attribute.FileTime.fromMillis(1_000_000)) + Files.setLastModifiedTime(newer, java.nio.file.attribute.FileTime.fromMillis(2_000_000)) + // A stray non-transcript must not be offered as a session. + Files.writeString(dir.resolve("notes.txt"), "ignore me") + + assertEquals(dir, SessionStore.projectDir("/tmp/proj")) + assertEquals(listOf(newer, older), SessionStore.listFiles("/tmp/proj")) + } finally { + System.setProperty("user.home", originalHome) + Files.walk(home).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefBridgeTest.kt b/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefBridgeTest.kt index 810bcb93..bd3e8fe8 100644 --- a/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefBridgeTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/ui/jcef/JcefBridgeTest.kt @@ -269,6 +269,52 @@ class JcefBridgeTest { assertEquals("task42", m.taskId) } + // ── "Claude Code was not found" boot card ──────────────────────────────────────────────────────────── + + @Test + fun `parse installClaude carries the method id`() { + val m = JcefBridge.parse("""{"type":"installClaude","method":"apt"}""") as JcefBridge.Msg.InstallClaude + assertEquals("apt", m.method) + } + + @Test + fun `parse setBinaryPath carries the path`() { + val m = JcefBridge.parse("""{"type":"setBinaryPath","path":"/opt/claude/claude"}""") as JcefBridge.Msg.SetBinaryPath + assertEquals("/opt/claude/claude", m.path) + } + + @Test + fun `parse recheckBinary`() { + assertEquals(JcefBridge.Msg.RecheckBinary, JcefBridge.parse("""{"type":"recheckBinary"}""")) + } + + // ── sign-in card ───────────────────────────────────────────────────────────────────────────────────── + + @Test + fun `parse loginSubscription cancelLogin dismissAuth logout`() { + assertEquals(JcefBridge.Msg.LoginSubscription, JcefBridge.parse("""{"type":"loginSubscription"}""")) + assertEquals(JcefBridge.Msg.CancelLogin, JcefBridge.parse("""{"type":"cancelLogin"}""")) + assertEquals(JcefBridge.Msg.DismissAuth, JcefBridge.parse("""{"type":"dismissAuth"}""")) + assertEquals(JcefBridge.Msg.Logout, JcefBridge.parse("""{"type":"logout"}""")) + } + + @Test + fun `parse useApiKey and submitLoginCode carry their secret verbatim`() { + val key = JcefBridge.parse("""{"type":"useApiKey","key":"sk-ant-test-123"}""") as JcefBridge.Msg.UseApiKey + assertEquals("sk-ant-test-123", key.key) + val code = JcefBridge.parse("""{"type":"submitLoginCode","code":"ABC-42"}""") as JcefBridge.Msg.SubmitLoginCode + assertEquals("ABC-42", code.code) + } + + @Test + fun `jsString escapes what would break out of a host exec call`() { + assertEquals("\"plain\"", JcefBridge.jsString("plain")) + // A quote+paren payload must come back inert, and control chars must be escaped, or a message + // containing them would terminate the JS string it is embedded in. + assertEquals("\"a\\\"b\"", JcefBridge.jsString("a\"b")) + assertEquals("\"line\\nbreak\"", JcefBridge.jsString("line\nbreak")) + } + // ── jump-to-code links ─────────────────────────────────────────────────────────────────────────────── @Test diff --git a/src/test/resources/fixtures/rate_limit.jsonl b/src/test/resources/fixtures/rate_limit.jsonl index 0c726e72..71be1462 100644 --- a/src/test/resources/fixtures/rate_limit.jsonl +++ b/src/test/resources/fixtures/rate_limit.jsonl @@ -1,5 +1,5 @@ {"type":"system","subtype":"init","session_id":"55555555-5555-5555-5555-555555555555","model":"claude-opus-4-8","cwd":"/tmp/project","permissionMode":"default"} {"_sleep_ms":600} -{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","rateLimitType":"five_hour","utilization":92.5,"resetsAt":1893456000}} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","rateLimitType":"five_hour","utilization":0.925,"resetsAt":1893456000}} {"type":"assistant","message":{"id":"msg_r1","role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"Heads up, you are near your quota."}]}} {"type":"result","subtype":"success","result":"Heads up, you are near your quota.","session_id":"55555555-5555-5555-5555-555555555555","num_turns":1}