Skip to content

fix(config): /config reports what is actually in paddock.config.yaml, and null clears instead of zeroing (#722, #723) - #757

Merged
edspencer merged 2 commits into
mainfrom
fix/instance-config-pending-values
Aug 7, 2026
Merged

fix(config): /config reports what is actually in paddock.config.yaml, and null clears instead of zeroing (#722, #723)#757
edspencer merged 2 commits into
mainfrom
fix/instance-config-pending-values

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Fixes #722. Fixes #723.

#722 — three symptoms, one root cause

buildInstanceConfig built the GET response out of the boot-frozen
PaddockConfig and never read paddock.config.yaml. So a GET /api/instance-config could not observe any write — not another tab's, not even
one the same client had made a millisecond earlier. All three reported defects
fall out of that:

  1. A successful save visibly un-did itself. save() re-fetches and clears
    edits; the re-fetch returned the pre-save values, so setting OVERVIEW.md
    max tokens
    to 1234 wrote 1234 to disk, showed a green "Saved to disk",
    and put 2000 back in the box.
  2. Two tabs silently last-writer-won, with nothing — not polling, not an
    ETag — able to reveal it, because the value a client would have polled for was
    never read from the file.
  3. restartRequired was hardcoded false, because there was nothing to
    compare against.

So it is one fix, as the issue suggested. Each field now reports two values:

field meaning
value in force now — what the running process resolved at boot
pendingValue pending — what the file says this instant, i.e. what a restart would load
pendingRestart those two differ

restartRequired is now pendingRestart over all fields rather than an
assertion, and the response carries a configVersion (a fingerprint of the file)
and a configFileError for a file that exists but won't parse — the screen that
exists to fix a broken config should say it is broken rather than report
"nothing pending".

The editor binds to pendingValue. That one substitution is what makes the
screen honest: it is an editor for the file, so a save round-trips, another
tab's write shows up on the next load, and where the two disagree the field shows
an In force now: line and the banner says a restart is outstanding.

Pending values are computed for editable, non-env-shadowed fields only: an
env-shadowed field resolves to the same env value after a restart, and the
read-only advanced bindings are normalised at boot (paths canonicalised, port
Number()-ed), so comparing them against raw file text would manufacture
divergence that isn't there. A field the frozen config leaves unset is compared
as its documented default, so e.g. models — unset, therefore null — doesn't
read as permanently diverging from the catalog list its absence implies.

Two tabs: visible and refused

Rendering the file makes a concurrent write visible on the next load, but a
tab that already has the page open would still clobber the other one. So a save
is now conditional: the UI echoes the configVersion it read back as
expectedVersion, and a write composed against a stale snapshot gets a 409
with the file left alone. The client keeps the operator's edits, reloads so they
can see what the other writer did, and lets them save again deliberately.
expectedVersion is optional, so curl and scripts write unconditionally as
before.

#723null on a numeric field cleared, not zeroed

The PUT contract is that a null deletes the key. nonNegInt used
Number(raw), and Number(null) === 0 is a finite non-negative integer, so
{"recovery.maxRetries": null} wrote 0 — recovery stops retrying entirely.
The validator now has the same explicit null / "" / undefined branch as its
optNonNegNumber sibling, and a deliberate 0 still works.

maxSpawnDepth had the identical hole in its own validator (a "restore the
default" wrote depth 0, which takes the self-MCP away from every child) and is
fixed the same way.

The same missing type check let Number()'s other coercions through —
overviewMaxTokens: true wrote 1, [7] wrote 7, debounceMs: false wrote
0. Numeric fields now accept a number or a numeric string and nothing else.

Two smaller holes from the same audit

  • Env-shadowed fields were writable via the API. The UI already rendered them
    read-only, but validatePatch didn't check, so a PUT returned 200 +
    restartRequired: true for a write that could never take effect. Now a 400
    naming the variable. (Tradeoff: you can no longer stage a file value in
    preparation for removing the env var — an option the UI never offered anyway.)
  • No upper bound on numeric or string fields. A 200 KB brand.name produced
    a 200 KB paddock.config.yaml for every boot to parse. Numbers cap at 1e9,
    plain strings at 1024 characters, list fields at 64 entries; environmentPrompt
    keeps its own 32 KiB cap.

Nothing that worked was weakened

The existing patch validation is genuinely strong and is untouched: negative /
zero / fractional budgets, "abc", bad and wrong-case enums, unknown keys,
read-only keys, unknown model ids, an empty model list, non-hex accents, NUL
bytes and oversized prompts are all still rejected, __proto__ is still inert,
and the file still round-trips through the yaml Document API with operator
comments and unmanaged keys intact. The client's dirty-tracking still sends only
genuinely-changed keys — it just compares against the file now instead of the
boot config.

Tests

All new tests were confirmed failing on main (source reverted, tests kept):

  • Integration (instance-config.test.ts, the load-bearing one — this file
    covered the PUT but never asserted what a subsequent GET reported, which is
    exactly why /config: a successful save reverts the form to stale values, two tabs silently last-writer-wins, and restartRequired is hardcoded false #722 shipped): a write is visible to the next GET as
    pendingValue/pendingRestart; a second client sees the first's write and a
    stale conditional write is refused with the file intact; null clears the three
    recovery keys; env-shadowed and oversized writes are refused. 5 fail on main.
  • Unit: PUT /api/instance-config: a null on a nonNegInt field writes 0 instead of clearing the override #723's clear-vs-zero and the type checks; pending/effective
    computation including the cases that must not report divergence; a malformed
    file. 11 fail on main.
  • Web: the saved value survives the post-save re-fetch; the pending-restart
    banner; the 409 path keeps the edits and re-reads. 4 fail on main.
  • E2E: /config now actually saves — no test anywhere did — asserts the
    value is still on screen after the re-fetch and after a reload, then restores
    the instance's original value so the shared server is left as found. Verified
    failing against a main build.

Full suite green locally (server + web), npm run typecheck clean.

One thing this cannot cover: I could not restart a server mid-test either, so
"the pending value becomes the effective one after a restart" is asserted
through the loader (writeInstanceConfigloadConfigFile round-trip) rather
than through a real reboot. The two failure modes that worried the reporter —
a zeroed config and a bloated one — can no longer be written through this API
at all.

🤖 Generated with Claude Code

HomeLab Agent and others added 2 commits August 7, 2026 16:21
…onfig (#722, #723)

buildInstanceConfig never read paddock.config.yaml, so a GET could not observe
any write — a successful save re-fetched the pre-save values and looked like it
had reverted, two tabs clobbered each other with no client able to notice, and
restartRequired had to be a hardcoded false. Each field now reports `value`
(in force now) alongside `pendingValue` (what the file says, i.e. what a restart
would load); the editor binds to the latter and restartRequired falls out of the
comparison. Saves carry a configVersion so a stale write gets a 409 instead of
silently winning.

Also: a null on a nonNegInt field clears the key instead of writing 0 (#723,
Number(null) === 0), numeric fields reject booleans/arrays, env-shadowed fields
are refused by the API as well as the UI, and numeric/string/list fields have
upper bounds.

Co-Authored-By: Claude <noreply@anthropic.com>
maxSpawnDepth had #723's hole in its own validator — Number(null) is 0, and
depth 0 is valid, so "restore the default" wrote the one value that takes the
self-MCP away from every child. It now clears the key like its nonNegInt
siblings; an explicit 0 still works.

The e2e suite touched /config but never saved anything, which is how a screen
that discarded every save on screen shipped. It now saves, asserts the value is
still there after the post-save re-fetch and after a reload, and restores the
instance's original value so the shared server is left as it was found.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: 79f195b
Status: ✅  Deploy successful!
Preview URL: https://9c28e351.paddock-7u2.pages.dev
Branch Preview URL: https://fix-instance-config-pending.paddock-7u2.pages.dev

View logs

@edspencer
edspencer merged commit 8e7ba1d into main Aug 7, 2026
5 checks passed
@edspencer
edspencer deleted the fix/instance-config-pending-values branch August 7, 2026 22:09
@github-actions github-actions Bot mentioned this pull request Aug 7, 2026
edspencer pushed a commit that referenced this pull request Aug 8, 2026
instance-settings.md described a single always-on restart banner and a write
with no concurrency story. #757/#722/#723 replaced both.

- three banner states (configFileError / just-saved / restartRequired), and
  restartRequired is computed now, not hardcoded false
- value vs pendingValue vs pendingRestart, per field
- configVersion -> expectedVersion -> 409 config_conflict, opt-in
- the 400 on an env-shadowed write
- a save stamps schemaVersion: 1 when absent, which is what config-file.md
  promises
edspencer added a commit that referenced this pull request Aug 9, 2026
* docs(configuration,reference): start 0.66 refresh

* docs(configuration): headers/type:sse are carried, and host plugins work

herdctl 5.32.0 (Paddock 0.63.0) carries an MCP server's `type` and `headers`
verbatim on both the host-inherit and declared paths, and added the `plugins`
passthrough that makes host plugin inheritance real. Three pages still told
readers neither capability existed.

- config-file.md: rewrite the two "cannot be carried" cautions; replace the
  "inert today" plugins paragraph and the "none of them reach Paddock" block
  with the instructions x mcpServers truth table from claude-plugins.ts:48-52;
  add the mcpServers: schema table; note headers in the argv-exposure caution
  and the env:VAR leaf list; fourteen -> fifteen paddock_manage tools.
- environment.md: same for the PADDOCK_CLAUDE_MCP_SERVERS and
  PADDOCK_CLAUDE_INSTRUCTIONS rows, and the env:VAR leaf list.

* docs(configuration): the Config screen's 0.66.2 DTO

instance-settings.md described a single always-on restart banner and a write
with no concurrency story. #757/#722/#723 replaced both.

- three banner states (configFileError / just-saved / restartRequired), and
  restartRequired is computed now, not hardcoded false
- value vs pendingValue vs pendingRestart, per field
- configVersion -> expectedVersion -> 409 config_conflict, opt-in
- the 400 on an env-shadowed write
- a save stamps schemaVersion: 1 when absent, which is what config-file.md
  promises

* docs(reference): websocket.md was three frames and several fields behind

- add chat:queued_state, chat:queued_returned and chat:injected to the
  server->client table (all three exist and are handled by the client)
- chat:send and chat:set_queue carry attachments; set_queue's identity is qid,
  not the legacy ts (#245/#736) -- note added on why
- chat:tool_start/tool_call carry subagentType/description/hasSubagent (#429)
- chat:queued_flushed carries attachments (#728)
- the no-seq list was missing the three hub.broadcast frames
- replace the misleading "no chat:queued" note with a section on the four
  queue frames and why _returned is not a flag on _flushed

* docs(configuration,reference): remaining stale claims

- self-mcp.md frontmatter + mcp.md scope prose: fourteen -> fifteen
  paddock_manage tools (ALL_OPERATIONS = 3 read + 8 write + 4 trigger)
- api.md: the auth section named /api/health as the sole exemption; there are
  three groups (health, the compiled bundle, /mcp + its metadata). Also
  document the /api/root/* mount -- every workspace route is registered once
  and mounted twice, which is why the root workspace's key is ""
- hooks.md: add the two trigger routes it was missing (GET .../runtime and
  POST .../:name/run); schedules.md already listed all six
- openapi.md: note /open-api/yaml
- environment.md: add the CLAUDE_SECURESTORAGE_CONFIG_DIR row

* docs(configuration,reference): cite the filed code-bug issues

- websocket.md: a note that the page documents what the server SENDS, citing
  #772 (chat:injected absent from the ServerMessage union) and #773 (the web
  client's mirrored types are stale on the #728 queue attachments), so neither
  is later used to 'correct' the page back
- instance-settings.md: the Self MCP projects field's help text says only
  'create whole new projects' but the flag also gates promote_project, which
  clones a caller-supplied URL (#775) -- document the grant, not the string
- retire the 'notebook project to repo-backed' phrasing in the two capability
  rows, matching self-mcp.md's managed/unmanaged vocabulary

---------

Co-authored-by: HomeLab Agent <homelab-infra@valfenda.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant