Repository navigation
Settings v2: 7 new settings, write-safety hardening, schema-drift monitoring - #99
Merged
Merged
Conversation
…hema drift Grows the Settings page into a curated "game settings menu": seven new Claude Code settings (spell check, editor mode, notification channel, commit attribution, default model, update channel, verbose output) rendered generically from a manifest, plus restart badges, visible defaults, reset-to-default, and prerequisite detection. Hardens the settings.json write path in api/routers/settings.py, since this dashboard now writes to the real config file Claude Code reads at every session start: - Atomic write via a per-request-unique temp file + os.replace(), safe under concurrent requests (serialized with an asyncio.Lock around the whole read-merge-validate-write sequence) - Symlink-safe: writes through a symlinked settings.json instead of replacing the symlink itself - Single rolling backup (settings.json.karma-bak) before every write - Deep merge replacing the old shallow dict.update, with null-deletes-key semantics (including nested keys, and keys that were never on disk) - jsonschema validation against a vendored Claude Code settings schema before every write, tolerant of unknown top-level keys but strict on nested objects that declare additionalProperties: false - Clean 400 instead of a raw 500 on a pathologically deeply-nested body Adds a weekly GitHub Actions workflow (schema-drift.yml + scripts/check_schema_drift.py) that diffs the vendored schema/reference snapshots against live upstream sources, opens an issue when a karma-exposed setting changes shape, and proposes a snapshot-refresh PR. Adds a Settings Guide doc (docs/about/settings-guide.md) wired through the existing docs router and About page. 21 new/updated tests in api/tests/api/test_settings.py cover the write path adversarially: concurrent writes, null-on-absent-parent, typo'd keys inside strict nested schema objects, symlinks, and deeply-nested payloads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d keys The vendored schema/reference snapshots covered the full ~150-property upstream Claude Code settings surface, but drift-tracking only ever needed the 12 keys in schema/watched-keys.json (the ones karma's Settings page actually reads or writes). Trims both files down to that scope: - schema/claude-code-settings.schema.json: 5107 -> 250 lines - schema/settings-reference.md: 4902 -> 261 lines scripts/check_schema_drift.py now trims the freshly-fetched live schema and reference doc to the same watched-key scope before diffing or using either as a snapshot-update candidate -- otherwise every run would treat the ~140 keys outside our tracked set as drift, and the auto-generated snapshot-refresh PR would silently re-inflate both files back to full size the moment any real drift fired. Verified locally: a no-op dry run (nothing changed) reports zero drift, a mutated key is still caught, and a garbage/truncated fetch still fails loudly rather than passing through. Two tests in api/tests/api/test_settings.py that specifically exercised validation for keys outside the watched set (autoCompactEnabled, hooks) no longer apply, since that coverage is the direct, intended tradeoff of this scope-down -- rewritten to document the new boundary explicitly and to prove the schema validator still does real, independent work within the 12 watched keys (statusLine's nested strictness, permissions.allow's pattern constraint, neither enforced by Pydantic alone). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ruff E741 flagged `l` as an ambiguous variable name (too easily confused with `1`/`I`) in filter_reference_to_watched_keys — renamed to `line` and re-ran ruff format, which reflowed the resulting multi-line comprehension. This is what failed CI's "API guard tests + lint (ubuntu)" job. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7 of 12 tasks
JayantDevkar
approved these changes
Sep 18, 2026
JayantDevkar
left a comment
Owner
There was a problem hiding this comment.
Approving. The write path is a real improvement over what's on main (shallow dict.update() + non-atomic open("w"), which could clobber nested keys and truncate on crash). I found no data-loss bug in the new path: symlink handling, permission preservation, unique temp file, rolling backup, null-deletes-key and the lock all read correct and are covered by the tests.
Follow-ups, in the order I'd do them (in the body rather than inline so they don't block the merge):
- Server-side allowlist of writable keys —
api/routers/settings.py:303keepsextra: "allow"and the vendored schema root isadditionalProperties: true, so the endpoint will write any top-level key that validates, including ones Claude Code executes (hooks,env,apiKeyHelper). That's pre-existing onmain, not introduced here, andtest_settings.py:286documents it as deliberate scope — but the dashboard only ever needs the keys inschema/watched-keys.json. Restricting PUT to those, plus the narrow:5180-only origin gate #101 introduces, would make "write-safety hardening" complete. I'd like this one soon. fsyncbeforeos.replace—settings.py:117-127. The docstring says a crash can never leave the file truncated; that holds for a process crash but not power loss (rename journaled, data blocks not).f.flush(); os.fsync(f.fileno())inside thewith.- Reset doesn't cancel a pending debounced save —
+page.sveltehandleManifestResetneverclearTimeout(manifestTimeouts[entry.key]). Type in "Default Model", hit reset within 500ms →{model: null}saves, then the orphaned timer rewrites the value you just cleared. - Pin
peter-evans/create-pull-request@v6to a SHA —.github/workflows/schema-drift.yml:101runs withcontents: write+pull-requests: write. The rest of the workflow is clean: schedule/dispatch triggers only, no${{ }}inrun:bodies, remote text reachesghonly via--body-file. - Smaller:
check_schema_drift.py:323computes the digest from the trimmed schema, so every watched change opens two issues.:301ensure_ascii=Truevsjq -Sraw UTF-8 will go permanently red on the first non-ASCII char upstream.StatusLineUpdate.typeallows"disabled"but the schema requiresconst "command".jsonschemais inrequirements.txtbut notpyproject.toml, and it's a hard import frommain.py.- A JSONC
settings.json(the schema allows trailing commas) 500s on GET/PUT — safe, but a dead end in the UI.
Not checked: the running UI, schema/settings-reference.md prose, Windows behaviour.
the-non-expert
added a commit
that referenced
this pull request
Sep 19, 2026
…n, no orphan menu Review feedback on #101, plus the rebase onto main (#99/#100/#102). - Memoize the transcript scan on (path, st_mtime_ns, st_size). The single- session GET is polled ~1s per open tab and, when RC has never been used, found no bridge_status line anywhere — so every uncached hit re-read and decoded the whole multi-MB chain. A JSONL transcript is append-only, so an unchanged transcript is now scanned exactly once. - Verify a tmux pane still owns the recorded pid before enabling the toggle. pid_is_live_claude accepts node/bun/deno, so a recycled pid in a recycled pane could have enabled RC on a *different* Claude session. The pane's pane_pid must now be that pid or an ancestor of it; unverifiable => refused. - Require Origin on the toggle, not just "trusted if present". For a keystroke-injecting endpoint, no-Origin isn't worth the benefit of the doubt: this closes DNS-rebinding and the no-Origin-same-origin-POST corner. - Drop the "Esc-dismisses" claim in read_remote_control_state — nothing here sends Esc. A stale "off" (session launched with --remote-control, no bridge_status line) meant the ON click opened the *disconnect* menu and left it open in an unfocused terminal, where the user's next Enter picks an item. Unconfirmed ON now raises the terminal like the OFF path and says so. ConversationHeader: #100's ResumeSessionButton supersedes this branch's live-session hide (it focuses the terminal when live, which is useful), so the rebase keeps #100's version outright and drops the copy-resume block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JayantDevkar
added a commit
that referenced
this pull request
Oct 21, 2026
* feat(live-sessions): per-session Remote Control toggle
Add a toggle on the live-session header to turn Claude Code Remote
Control on/off for that session. Claude Code exposes no local API for
this, so Karma drives it by typing `/remote-control` into the session's
terminal (tmux / macOS Terminal.app / iTerm2).
- POST /live-sessions/{id}/remote-control {desired}: reads current state
from the transcript's bridge_status lines, types the command, confirms
from the transcript. Honest by construction — sent=false means nothing
was typed; sent=true/confirmed=false means keys went but the transcript
hasn't caught up.
- ON is fully automated. OFF opens Claude Code's interactive "Disconnect
Remote Control" menu and raises the terminal; the user finishes it
there (nothing drives that menu reliably from outside the process).
- Only WAITING_INPUT / STARTING / ENDED are blocked — a slash command
typed during a tool run just queues.
- CSRF: rc_trusted_origins (narrower than cors_origins) + an X-Karma-RC
header, which also required adding X-Karma-RC to cors_allow_headers so
the browser CORS preflight passes.
- state read confined to ~/.claude/projects; only https://claude.ai URLs
surfaced to the frontend <a href>; a process-wide asyncio.Lock
serialises toggles.
remote_control + can_remote_control are computed only on the
single-session GET (they open the JSONL — too costly for the 1s list
poll).
ConversationHeader also moves the live-sync spinner to a badge by the
H1 and hides `resume` on live sessions. api-types.ts carries two
incidental Prettier line-collapses.
33 endpoint/unit tests. ON + OFF verified end-to-end against a throwaway
Terminal.app session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLBBkC227GsETwuAfB6SyN
* style(rc-tests): wrap multi-line lambda body in parens for ruff 0.16
CI installs ruff unpinned (now 0.16.5); its formatter wraps the
multi-line lambda in test_disable_opens_menu_and_raises_terminal.
No behaviour change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLBBkC227GsETwuAfB6SyN
* fix(rc): address review — memoized scan, pane ownership, strict Origin, no orphan menu
Review feedback on #101, plus the rebase onto main (#99/#100/#102).
- Memoize the transcript scan on (path, st_mtime_ns, st_size). The single-
session GET is polled ~1s per open tab and, when RC has never been used,
found no bridge_status line anywhere — so every uncached hit re-read and
decoded the whole multi-MB chain. A JSONL transcript is append-only, so an
unchanged transcript is now scanned exactly once.
- Verify a tmux pane still owns the recorded pid before enabling the toggle.
pid_is_live_claude accepts node/bun/deno, so a recycled pid in a recycled
pane could have enabled RC on a *different* Claude session. The pane's
pane_pid must now be that pid or an ancestor of it; unverifiable => refused.
- Require Origin on the toggle, not just "trusted if present". For a
keystroke-injecting endpoint, no-Origin isn't worth the benefit of the
doubt: this closes DNS-rebinding and the no-Origin-same-origin-POST corner.
- Drop the "Esc-dismisses" claim in read_remote_control_state — nothing here
sends Esc. A stale "off" (session launched with --remote-control, no
bridge_status line) meant the ON click opened the *disconnect* menu and left
it open in an unfocused terminal, where the user's next Enter picks an item.
Unconfirmed ON now raises the terminal like the OFF path and says so.
ConversationHeader: #100's ResumeSessionButton supersedes this branch's
live-session hide (it focuses the terminal when live, which is useful), so the
rebase keeps #100's version outright and drops the copy-resume block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(rc): verify the tmux pane-ownership gate against a real tmux
The pane-ownership check added in a08634c was covered only by mocks, so a
wrong `display-message` invocation would have silently withheld the toggle
from every tmux user while looking fine on Terminal.app / iTerm2.
Adds an integration test that drives a throwaway tmux session end to end:
`_tmux_pane_pid` must agree with tmux, the pane's own process and a
descendant of it (claude sits under the pane's shell in the real setup) are
owned, and init / an unrelated process / a nonexistent pane are not.
Skipped when tmux is absent, so CI and non-tmux machines are unaffected.
Verified locally on tmux 3.7c: PASSED, not skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: gitignore storygraph's local SQLite artifacts
.storygraph.db and its -wal/-shm sidecars are an external tool's local
artifact that sits in the repo root and shows up as untracked noise in every
`git status`. Nothing in this repo references them — karma's own database is
~/.claude_karma/metadata.db.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* review: run the terminal gate off the event loop, isolate the tmux test on a private server, aria-disabled toggle, one-line comments
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Jayant Devkar <55962509+JayantDevkar@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
api/routers/settings.py's write path — this dashboard writes to the realsettings.jsonClaude Code reads at every session start: atomic per-request-unique temp file +os.replace(), serialized under anasyncio.Lockso concurrent writes can't corrupt the file, symlink-safe, single rolling backup, deep merge with proper null-deletes-key semantics, and jsonschema validation against a vendored settings schema.This branch went through two rounds of adversarial review focused specifically on the write path (concurrency, malformed/deeply-nested payloads, symlinked config files, schema-validation edge cases) — all findings from both rounds were fixed and independently re-verified against reproductions, not just the added tests.
Test plan
pytest api/tests/api/test_settings.py -v— 21/21 passing, covering concurrent writes, null-on-absent-parent, typo'd keys inside strict nested schema objects, symlinked settings.json, and deeply-nested payloadspytest tests/suite — passes except 5 pre-existing failures intest_background_shells_router.py, unrelated to this branch🤖 Generated with Claude Code