Skip to content

fix: route every direct preference write through one checked door - #379

Merged
KrasimirKralev merged 4 commits into
betafrom
fix/preference-write-chokepoint
Aug 11, 2026
Merged

fix: route every direct preference write through one checked door#379
KrasimirKralev merged 4 commits into
betafrom
fix/preference-write-chokepoint

Conversation

@KrasimirKralev

@KrasimirKralev KrasimirKralev commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #378, from reviewing that diff. One real gap, one inconsistency, and three tidy-ups.

The gap: a third direct writer

#378 applied the preference rules at two writers that reach the config store without going through POST /setup-api/preferences. There is a thirdsrc/app/setup-api/apps/uninstall/route.ts. It reads installed_apps and installed_meta, drops one entry and writes the rest back, so it carries whatever it read straight out again. That is exactly the case the other two were fixed for, and the docstring #378 added claimed every door was covered. It wasn't.

Rather than restate the same three lines at a third call site, this adds setPreferences() (src/lib/preference-store.ts) and points all three at it.

Not folded into config-store.setMany, deliberately: of its 17 call sites, 13 write things that are not preferences at all — provider tokens, wifi_configured, ai_model_configured, updater state. A 4096-character cap and a ban on control characters would be wrong for an opaque secret, so setMany stays a general config writer and the preference rules sit in front of it.

Related: sanitizePreferenceWrites now leaves keys outside the pref: namespace alone. It previously fell through and validated them as preferences, which is a contract its name does not promise — and is what made the deeper placement unsafe.

The inconsistency: one label, two limits

#378 checked the project name in initProject. The same user-facing label also arrives through webapp create and code build, which reach deployWebapp — documented in that file as the chokepoint those two share. Net effect was one label capped at 60 through one door and unbounded through the others, while the MCP schema already declares zText(60) for both.

The rule now applies at deployWebapp too. The webapps route mapped every thrown error to a 500; a refused field is the caller's to fix, so ValidationError answers 400 there as it already did on the code route.

Renamed validateProjectNameassertProjectName, matching assertMutableTarget in the same file — validateProjectId sits ten lines away and returns a boolean, so the two contracts should not share a prefix.

Tidy-ups

  • PREFERENCE_KEY_PREFIX was introduced in fix: validate preference input, bound logged fields, set an explicit redirect policy #378 and then not used where the prefix is actually taken apart, leaving a bare slice(5) tied to its length by nothing.
  • The key-cap comment was wrong. It said every caller asks for a single key; the largest is the preferences_get MCP tool, which sends its whole readable-prefs allowlist in one request (9 keys today). Corrected, with the headroom stated. The cap is now counted before the allowlist filter, so it bounds what a request names rather than what survives it.
  • sanitizePreferenceValue skipped a second walk that could only reach the answer the first one gave — a prune that dropped nothing cannot change the verdict.
  • CLOSED_DOMAINS is a Map, so a key named after something on Object.prototype reads as "no domain" by construction and the hasOwnProperty helper goes away.
  • The proxy copy of logSafe had drifted from the module it restates on the two points that module documents: it wrote U+FFFD as a literal glyph and accepted a non-string. Matched to the source; unused default and constant dropped.
  • Tests assert the whole outcome instead of short-circuiting through .ok, so a failure shows what was dropped rather than a boolean. The looped licence cases are split into named ones, their temp directories are cleaned up, and two environment names that never existed are gone.

Verification

Full unit suite: the same 29 pre-existing failures as beta (Windows-only — path separators, 0600 modes, symlinks, exec bits), 2392 passing. eslint unchanged at 92 problems / 22 errors. Typecheck shows the same two pre-existing errors in files this branch does not touch.

Summary by CodeRabbit

  • Bug Fixes
    • Improved preference validation and synchronization during app installation, removal, and updates.
    • Preference requests now enforce key limits consistently and preserve unrelated settings.
    • Invalid webapp submissions now return a clear 400 error instead of a server error.
    • Project and webapp names are validated consistently before changes are saved.
  • Tests
    • Expanded coverage for preference filtering, invalid licenses, project validation, and webapp error handling.
    • Improved test cleanup and reliability.

The uninstall route is a third writer that reaches the config store without
going through POST /setup-api/preferences. It reads installed_apps and
installed_meta, drops one entry and writes the rest back, so it carries
whatever it read straight out again — the case the other two doors were
just fixed for.

Add setPreferences() and point all three at it, so the rules are applied
in one place rather than restated per call site. Deliberately not folded
into config-store.setMany: most of what that writes is not a preference
(provider tokens, setup flags, updater state) and a length cap and a ban
on control characters would be wrong for an opaque secret.

sanitizePreferenceWrites now leaves keys outside the pref: namespace
alone, so its contract matches its name.
Checking it only in initProject left the same user-facing label capped at
60 through `code init` and unbounded through `webapp create` and
`code build`. deployWebapp is already documented as the chokepoint those
two share, so the rule goes there as well.

The webapps route mapped every thrown error to a 500; a refused field is
the caller-s to fix, so ValidationError now answers 400 as it already does
on the code route. Renamed to assertProjectName, matching assertMutableTarget
in the same file — validateProjectId next to it returns a boolean, and the
two contracts should not share a prefix.
PREFERENCE_KEY_PREFIX was introduced and then not used where the prefix is
actually taken apart, leaving a bare slice(5) tied to its length by nothing.

Also corrects the key-cap comment: the largest caller is the preferences_get
MCP tool, which sends its whole readable-prefs allowlist in one request, not
a single key. The cap is now counted before the allowlist filter so it bounds
what a request names rather than what survives it.
The proxy copy of logSafe had drifted from the module it restates on the
two points that module documents: it wrote U+FFFD as a literal glyph, and
it accepted a non-string. Match the source, and drop the unused default
and constant — it has one call site with an explicit cap.

Tests: assert the whole outcome rather than short-circuiting through .ok,
so a failure shows what was dropped instead of a boolean. Split the looped
licence cases into named ones, clean up the temp directories they create,
and drop two environment names that never existed.
@KrasimirKralev
KrasimirKralev requested a review from a team as a code owner August 11, 2026 22:35
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The changes centralize preference sanitization and persistence, enforce shared preference-key handling, validate project names before writes, map validation errors to HTTP 400, and update logging, typing, and license-test utilities.

Changes

Preference storage boundaries

Layer / File(s) Summary
Preference sanitization and persistence
src/lib/preference-schema.ts, src/lib/preference-store.ts, src/tests/unit/preference-schema.test.ts
Preference sanitization now handles closed domains, collection pruning, and non-pref: keys. setPreferences filters updates before storage writes.
Installed-app and webapp preference synchronization
src/app/setup-api/apps/*/route.ts, src/lib/webapp-registry.ts
Preference synchronization now uses setPreferences.
Preference API key handling
src/app/setup-api/preferences/route.ts, src/tests/routes/preferences.test.ts
The route uses PREFERENCE_KEY_PREFIX and counts submitted names before allowlist filtering. Obsolete config.get mocks were removed.

Project and webapp validation

Layer / File(s) Summary
Project-name validation before writes
src/lib/code-projects.ts, src/app/setup-api/code/route.ts
assertProjectName validates names before project or webapp data is written. The validated name is stored and registered.
Validation error response
src/app/setup-api/webapps/route.ts, src/tests/routes/webapps.test.ts
ValidationError responses now return HTTP 400 with the error message.

Targeted maintenance updates

Layer / File(s) Summary
Logging and redirect-policy cleanup
scripts/hermes-dashboard-proxy.js, src/lib/hermes-dashboard-auth.ts
logSafe uses LOG_REPLACEMENT and explicit arguments. REDIRECT_POLICY uses inferred literal typing.
Edition-license test isolation
src/tests/unit/edition-license.test.ts
License tests use the single public-key variable, tracked temporary directories, centralized cleanup, and parameterized invalid-input cases.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: area: install, area: ui

Suggested reviewers: georgik77, yalexx

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: centralizing all direct preference writes through the checked setPreferences helper.
Description check ✅ Passed The description clearly explains the changes, rationale, affected paths, and verification results, although it omits several template sections and checklist confirmations.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/preference-write-chokepoint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🦀 ClawReview

Claws waving — here's what this change is about.

Follow-up to #378 that closes a gap in the preference-write safety net: the app-uninstall route was a third direct config-store writer that #378 missed, and this PR routes all three through a new setPreferences() helper in src/lib/preference-store.ts. It also closes a bypass in the project-name length cap — the deployWebapp chokepoint shared by the webapps route and code build was unbounded while initProject was not. Several smaller tidyups come along: the pref: prefix constant is now used consistently, CLOSED_DOMAINS becomes a Map, a redundant validation walk is pruned, and the logSafe proxy in the dashboard script is brought back in sync with the module it restates.

At a glance

  • 🔧 Fix · touches preference store + app install/uninstall routes + webapp registry + code-projects name validation
  • Base branch: beta · +134 source / +71 tests across 16 files
  • ✅ base beta matches the beta-first convention
  • ✅ conventional PR title
  • ✅ source changes come with test changes

Good to know

  • ℹ️ New file src/lib/preference-store.ts is the single checked door for all direct preference writers — three routes now share it instead of inlining the same sanitize call.
  • 🟡 sanitizePreferenceWrites previously fell through and applied preference rules to non-preference keys (tokens, setup flags). Fixed to pass those through untouched — the new test in preference-schema.test.ts covers this.
  • ℹ️ No tests added for preference-store.ts itself; coverage comes indirectly through the route tests that mock config-store.
  • ℹ️ Temp directories in edition-license tests are now cleaned up in afterAll; previously each test leaked a directory under the OS temp folder.

— ClawReview 🦀. I set the scene; CodeRabbit reviews the code; you decide. Conventions: docs.

@github-actions github-actions Bot added area: install Auto-triage area area: ui Auto-triage area labels Aug 11, 2026
const result: Record<string, unknown> = Object.create(null);
for (const key of keys) {
result[key] = allConfig[`pref:${key}`];
result[key] = allConfig[`${PREFERENCE_KEY_PREFIX}${key}`];
return NextResponse.json({ error: check.reason ?? "Invalid preference value" }, { status: 400 });
}
entries[`pref:${key}`] = value;
entries[`${PREFERENCE_KEY_PREFIX}${key}`] = value;
Comment thread src/lib/code-projects.ts
await fs.writeFile(
path.join(WEBAPPS_DIR, appId, "meta.json"),
JSON.stringify({ name: meta.name, color: meta.color || "#f97316", icon: meta.icon || "" }),
JSON.stringify({ name, color: meta.color || "#f97316", icon: meta.icon || "" }),
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

CI Summary

✅ Tests

  • Result: passed
  • View run
  • Coverage: statements 65.18%, branches 54.15%, functions 63.12%, lines 67.26%

✅ E2E

✅ E2E Install

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/hermes-dashboard-proxy.js`:
- Around line 115-119: Update the caller of logSafe in the configure route’s
model-name logging path to pass an explicit maximum length, such as 200 or the
existing shared maximum-length constant. Ensure the model name remains included
and the truncation suffix does not receive an undefined maxLength.

In `@src/tests/routes/webapps.test.ts`:
- Around line 22-28: Add a test in the webapp route tests that configures
deployWebapp to reject with the visible ValidationError mock, then asserts the
route returns HTTP 400 and the expected JSON error message. Keep the existing
empty-name test unchanged, since it does not exercise the err instanceof
ValidationError branch.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7e123355-cc95-4217-8906-b728d1f48898

📥 Commits

Reviewing files that changed from the base of the PR and between 4838f60 and aa5a4b8.

📒 Files selected for processing (16)
  • scripts/hermes-dashboard-proxy.js
  • src/app/setup-api/apps/install/route.ts
  • src/app/setup-api/apps/uninstall/route.ts
  • src/app/setup-api/code/route.ts
  • src/app/setup-api/preferences/route.ts
  • src/app/setup-api/webapps/route.ts
  • src/lib/code-projects.ts
  • src/lib/hermes-dashboard-auth.ts
  • src/lib/preference-schema.ts
  • src/lib/preference-store.ts
  • src/lib/webapp-registry.ts
  • src/tests/routes/preferences-language.test.ts
  • src/tests/routes/preferences.test.ts
  • src/tests/routes/webapps.test.ts
  • src/tests/unit/edition-license.test.ts
  • src/tests/unit/preference-schema.test.ts
💤 Files with no reviewable changes (2)
  • src/tests/routes/preferences.test.ts
  • src/tests/routes/preferences-language.test.ts

Comment on lines +115 to +119
function logSafe(s, maxLength) {
if (s.length <= maxLength) return s.replace(LOG_CONTROL_CHARACTERS, LOG_REPLACEMENT);
// Cut first, then sanitise the head only: every character the pattern matches
// is one UTF-16 code unit replaced by one, so no match can straddle the cut.
const head = s.slice(0, maxLength).replace(LOG_CONTROL_CHARACTERS, "�");
const head = s.slice(0, maxLength).replace(LOG_CONTROL_CHARACTERS, LOG_REPLACEMENT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pass the required maximum length at the caller.

logSafe no longer provides a default for maxLength, but src/app/setup-api/ai-models/configure/route.ts:387 still passes only one argument. JavaScript then produces ...[+NaN chars] and omits the model name. Pass the intended limit, such as 200, or a shared maximum-length constant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/hermes-dashboard-proxy.js` around lines 115 - 119, Update the caller
of logSafe in the configure route’s model-name logging path to pass an explicit
maximum length, such as 200 or the existing shared maximum-length constant.
Ensure the model name remains included and the truncation suffix does not
receive an undefined maxLength.

Comment on lines +22 to +28
// The route maps this to a 400; deployWebapp throws it for a name it refuses.
ValidationError: class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the ValidationError response path.

The mock class is correct, but no test throws it. The existing empty-name test returns before deployWebapp, so it does not cover err instanceof ValidationError. Configure deployWebapp to reject with new ValidationError(...) and assert the HTTP 400 response and JSON error message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/routes/webapps.test.ts` around lines 22 - 28, Add a test in the
webapp route tests that configures deployWebapp to reject with the visible
ValidationError mock, then asserts the route returns HTTP 400 and the expected
JSON error message. Keep the existing empty-name test unchanged, since it does
not exercise the err instanceof ValidationError branch.

@KrasimirKralev
KrasimirKralev merged commit accbc70 into beta Aug 11, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: install Auto-triage area area: ui Auto-triage area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants