Skip to content

Settings v2: 7 new settings, write-safety hardening, schema-drift monitoring - #99

Merged
JayantDevkar merged 3 commits into
mainfrom
settings-page-v2
Sep 18, 2026
Merged

JayantDevkar merged 3 commits into
mainfrom
settings-page-v2

Conversation

@the-non-expert

@the-non-expert the-non-expert commented Aug 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Adds 7 new Claude Code settings to the dashboard (spell check, editor mode, notification channel, commit attribution, default model, update channel, verbose output), rendered generically from a manifest with restart badges, visible defaults, and reset-to-default.
  • Hardens api/routers/settings.py's write path — this dashboard writes to the real settings.json Claude Code reads at every session start: atomic per-request-unique temp file + os.replace(), serialized under an asyncio.Lock so 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.
  • Adds a weekly GitHub Actions workflow that watches for upstream Claude Code settings schema drift and opens an issue/PR when a karma-exposed setting changes shape.
  • Adds a Settings Guide doc page.

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 payloads
  • Full pytest tests/ suite — passes except 5 pre-existing failures in test_background_shells_router.py, unrelated to this branch
  • Manual review at localhost (API :8020, frontend :5180) — settings page, About > Settings Guide

🤖 Generated with Claude Code

the-non-expert and others added 2 commits August 25, 2026 17:32
…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>

@JayantDevkar JayantDevkar left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. Server-side allowlist of writable keys — api/routers/settings.py:303 keeps extra: "allow" and the vendored schema root is additionalProperties: true, so the endpoint will write any top-level key that validates, including ones Claude Code executes (hooks, env, apiKeyHelper). That's pre-existing on main, not introduced here, and test_settings.py:286 documents it as deliberate scope — but the dashboard only ever needs the keys in schema/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.
  2. fsync before os.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 the with.
  3. Reset doesn't cancel a pending debounced save — +page.svelte handleManifestReset never clearTimeout(manifestTimeouts[entry.key]). Type in "Default Model", hit reset within 500ms → {model: null} saves, then the orphaned timer rewrites the value you just cleared.
  4. Pin peter-evans/create-pull-request@v6 to a SHA — .github/workflows/schema-drift.yml:101 runs with contents: write + pull-requests: write. The rest of the workflow is clean: schedule/dispatch triggers only, no ${{ }} in run: bodies, remote text reaches gh only via --body-file.
  5. Smaller:
    • check_schema_drift.py:323 computes the digest from the trimmed schema, so every watched change opens two issues.
    • :301 ensure_ascii=True vs jq -S raw UTF-8 will go permanently red on the first non-ASCII char upstream.
    • StatusLineUpdate.type allows "disabled" but the schema requires const "command".
    • jsonschema is in requirements.txt but not pyproject.toml, and it's a hard import from main.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.

@JayantDevkar
JayantDevkar merged commit 98442cd into main Sep 18, 2026
28 checks passed
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
JayantDevkar deleted the settings-page-v2 branch October 21, 2026 18:22
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants