Skip to content

feat(sleep): local control-panel dashboard - #152

Open
Alphaxalchemy wants to merge 2 commits into
microsoft:mainfrom
Alphaxalchemy:feat/sleep-dashboard
Open

feat(sleep): local control-panel dashboard#152
Alphaxalchemy wants to merge 2 commits into
microsoft:mainfrom
Alphaxalchemy:feat/sleep-dashboard

Conversation

@Alphaxalchemy

@Alphaxalchemy Alphaxalchemy commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Local control-panel dashboard for SkillOpt-Sleep

A zero-dependency (stdlib http.server) web UI over one project's sleep
pipeline, served on 127.0.0.1.

Rebased onto current main. #151 has merged, so its evidence-chain commit is
dropped — this PR is now just the dashboard and its tests (4 files).

Why

#151 makes every night's chain recorded; this PR makes it inspectable and
steerable by a human
. The sleep gate is strict by design, and when it rejects a
night there was no way to see why without spelunking JSONL by hand — and no way
to adjust the four prompts driving the pipeline without editing source. If the
goal is validating the miner and evolving the prompts, a human needs a place to
read the exchanges and try variants first.

What

skillopt_sleep/dashboard.py + dashboard.html — laid out to mirror the
actual data flow:

  • per-stage role / model / prompt display, with live prompt editing through
    the registry (overrides apply on the next model call);
  • a per-night evidentiary chain browser over evidence.jsonl — harvest,
    miner exchanges, mined tasks + checks, replay attempts, reflect edits;
  • the gate math spelled out (baseline/candidate formula, trials, decision);
  • config editor, run / dry-run launcher, and explicit adopt (nothing
    auto-adopts from the UI).

CLI: python -m skillopt_sleep dashboard [--port] [--no-browser].

Security boundary

Binding to loopback is not an authorization boundary. Any page in the user's
browser can POST cross-origin to 127.0.0.1, and a hostile name resolving to
loopback (DNS rebinding) makes those requests look local. Since these endpoints
start real runs, adopt artifacts, and rewrite config and prompts, every request
passes through _authorize:

  • loopback Host on every request, GET included, with the port matching the
    bound port. This is what defeats rebinding — the browser sends the attacker's
    name, not ours.
  • state-changing POSTs additionally require a trusted Origin (missing and
    foreign both rejected), an application/json content type, and an unguessable
    per-process capability token in a custom header.

Each is independently sufficient to stop a cross-origin page: a form cannot set a
custom header or a JSON content type without a preflight, which this server
deliberately does not answer. No CORS headers are emitted anywhere, so a foreign
origin cannot read responses either. The token is minted per server process and
substituted into the served HTML, so it never travels in a URL, and restarting
the dashboard invalidates every previously served page.

Request bodies are validated rather than coerced: absent, empty, malformed,
non-object and oversized bodies each get a specific 4xx instead of silently
becoming {}.

Tests

tests/test_dashboard_security.py — 25 tests. Every negative case asserts twice:
that the request was refused, and that the side effect did not happen (a 403
that still started a run would pass a status-code-only test).

Covers foreign Host across all seven endpoints, wrong and absent port,
rebinding names, foreign/missing/null/lookalike Origins, form, text/plain,
multipart and absent content types, missing/wrong/cross-process tokens, empty,
malformed, non-object and oversized bodies, absence of CORS headers, unanswered
preflight, and the reported vector verbatim. Same-origin JSON happy paths for
config, prompts, run and adopt are retained.

Full suite: 20 pre-existing failures on this Windows checkout, byte-identical to
pristine main (e7014cd) — no new failures.

Series

Part 2 of 3: #151 (evidence chain + prompt registry, merged) → this PR
#153 (Antigravity harvest source, per the "more sources" direction of #97).

@Yif-Yang Yif-Yang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for building this local dashboard—the observability and prompt-steering direction is useful. We reviewed the current head (fd6dc429).

Since #151 has now merged, please first rebase onto current main and drop its duplicate evidence-chain commit, leaving only the dashboard changes here.

One security issue blocks merging: binding to 127.0.0.1 does not by itself protect these privileged POST endpoints from browser-origin attacks. They can rewrite config/prompts, launch a real run, or adopt artifacts. For example, an empty cross-origin form POST to /api/run currently parses as {} and starts a run.

Please:

  1. Validate the loopback Host on every request.
  2. For every state-changing POST, require a matching trusted Origin, application/json, and an unguessable per-process capability/CSRF token in a custom header. Reject missing/foreign origins, invalid content types/tokens, and do not enable permissive CORS.
  3. Add negative tests proving foreign Host/Origin, missing Origin, form or text/plain bodies, and invalid tokens cannot change config/prompts, start a run, or adopt anything, while retaining same-origin JSON happy-path tests.

Once rebased and covered by those tests, we will re-review promptly.

@Yif-Yang

Copy link
Copy Markdown
Contributor

Hi @Alphaxalchemy — following up after rechecking the current head. The main blocker is that the local dashboard's mutating endpoints have no request-authentication or CSRF capability. POST /api/run, /api/adopt, /api/config, and /api/prompts accept browser-origin requests without Host, Origin, content-type, or token validation, and an empty or malformed body is coerced to {}. Binding to localhost alone does not prevent hostile cross-origin browser requests or DNS-rebinding-style attacks.

To move this forward, please:

  • Add a per-start capability/auth token for mutating endpoints.
  • Validate Host and Origin and require an application/json content type; reject malformed or empty bodies rather than treating them as defaults.
  • Add adversarial tests for cross-origin, malformed-body, and unauthorized mutation attempts.
  • Rebase onto current main to resolve the conflicts.

The dashboard direction is useful, but endpoints that can start runs, adopt skills, or modify configuration need this boundary before merge.

Alphaxalchemy pushed a commit to Alphaxalchemy/SkillOpt that referenced this pull request Aug 1, 2026
A zero-dependency (stdlib http.server) web UI over one project's sleep
pipeline, arranged to mirror the data flow: per-stage role/model/prompt
display with live prompt editing, a per-night evidentiary chain browser
over evidence.jsonl, the gate arithmetic spelled out, a config editor, a
run/dry-run launcher, and explicit adopt (nothing auto-adopts from the UI).

    python -m skillopt_sleep dashboard [--project DIR] [--port N]

Addresses the review on microsoft#152:

* Rebased onto current main; the evidence-chain commit landed as microsoft#151 and
  is dropped here. This commit is only the dashboard and its tests.

* Request authorization. Binding to 127.0.0.1 is not an authorization
  boundary — any page in the user's browser can POST cross-origin to
  loopback, and a hostile name resolving to loopback (DNS rebinding) makes
  those requests look local. Every request now passes through _authorize:

  - loopback Host is required on every request, GET included, with the
    port matching the bound port. This is what defeats rebinding: the
    browser sends the attacker's name, not ours.
  - state-changing POSTs additionally require a trusted Origin (missing
    and foreign are both rejected), an application/json content type, and
    an unguessable per-process capability token in a custom header.

  Each is independently sufficient to stop a cross-origin page: a form
  cannot set a custom header or a JSON content type without a preflight,
  which this server deliberately does not answer. No CORS headers are
  emitted anywhere, so foreign origins cannot read responses either.

  The token is minted per server process and substituted into the served
  HTML, so it never travels in a URL and restarting the dashboard
  invalidates every previously served page.

* Request bodies are validated, not coerced. An absent, empty, malformed,
  non-object or oversized body is now a specific 4xx instead of silently
  becoming {}. That coercion is what let a contentless cross-origin POST
  to /api/run start a real run. Oversized bodies are drained within a
  bound before the 413 so well-behaved clients read the status rather than
  a connection reset.

tests/test_dashboard_security.py adds 25 tests. Every negative case
asserts twice — that the request was refused, and that the side effect did
not happen, since a 403 that still started a run would pass a status-only
test. Covers foreign Host (all seven endpoints), wrong/absent port,
rebinding names, foreign/missing/null/lookalike Origins, form,
text/plain, multipart and absent content types, missing/wrong/
cross-process tokens, empty, malformed, non-object and oversized bodies,
absence of CORS headers, unanswered preflight, and the reported vector
verbatim (empty cross-origin form POST to /api/run). Same-origin JSON
happy paths for config, prompts, run and adopt are retained.
Copilot AI review requested due to automatic review settings August 1, 2026 17:59
@Alphaxalchemy

Alphaxalchemy commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the CSRF analysis was correct, including the specific vector. All of it is addressed in 3f23736, which replaces the previous head.

Rebase. Onto current main (e7014cd), with #151's evidence-chain commit dropped now that it has merged. This PR is a single commit over 4 files: dashboard.py, dashboard.html, the CLI subcommand, and a new test module. Mergeable and conflict-free.

1. Loopback Host on every request. Validated in _authorize for GET and POST alike, with the hostname in {127.0.0.1, localhost, ::1} and the port matching the bound port. This is the check that actually defeats DNS rebinding — a rebound name connects fine, but arrives carrying its own Host.

2. Origin, content type, and capability token on every state-changing POST.

  • Origin must be one of the three loopback origins for the bound port. Missing Origin is rejected too — its absence is not evidence of a same-origin caller, and the real page always sends one.
  • Content type must be application/json (parameters like ; charset=utf-8 allowed). This is what rules out application/x-www-form-urlencoded, text/plain and multipart/form-data — precisely the three a cross-origin <form> can send with no preflight.
  • Token: secrets.token_urlsafe(32), minted per server process, compared with hmac.compare_digest, and required in X-SkillOpt-Dashboard-Token. A custom header forces a preflight that this server deliberately does not answer.

The token is substituted into the served HTML at request time rather than passed in a URL, so it stays out of referrers, shell history and logs; only a same-origin document can read the page body, and restarting the dashboard invalidates every previously served page. The handler is now a per-server subclass, so the token and port cannot leak between servers.

No permissive CORS. No Access-Control-* header is emitted on any response, and OPTIONS is left unanswered rather than being given a permissive preflight. Both are asserted in tests.

Bodies are validated, not coerced. _body() used to swallow every failure into {} — which is exactly why a contentless POST to /api/run started a run. Absent, empty, malformed, non-object and oversized bodies now each get a specific 4xx. Oversized bodies are drained within a bound before the 413, so a well-behaved client reads the status instead of a connection reset mid-upload.

3. Negative tests. tests/test_dashboard_security.py, 25 tests. Every negative case asserts twice — that the request was refused, and that the side effect did not happen. A 403 that still started a run, wrote config, saved a prompt override or adopted a night would pass a status-code-only test, so each case ends with a check that all four side-effect probes are untouched.

Coverage: foreign Host across all seven endpoints, wrong port, absent port, rebinding names, foreign / missing / null / lookalike Origins (including http://127.0.0.1:PORT.evil.com and http://127.0.0.1.evil.com:PORT), form / text/plain / multipart / absent content types, missing / wrong / cross-process tokens, empty / malformed / non-object / oversized bodies, absence of CORS headers, unanswered preflight. Same-origin JSON happy paths for config, prompts, run and adopt are retained, plus the config allowlist and the localhost origin form.

Your reported vector is in there verbatim as test_the_documented_csrf_vector_cannot_start_a_run. Live, end to end:

=== the reported attack: empty cross-origin form POST to /api/run ===
  -> HTTP 403 {"error": "forbidden: bad origin"}
  -> runs started: 0

=== DNS-rebinding variant (attacker name resolving to loopback) ===
  -> HTTP 403 {"error": "forbidden: bad host"}
  -> runs started: 0

=== legitimate flow: page fetches token, then posts ===
  -> GET / HTTP 200, token injected (len 43)
  -> HTTP 200 {"ok": true, "mode": "run"}
  -> runs started: 1

Full suite: 275 tests, 20 failures — byte-identical to the same run against pristine main, so no new failures. (Those 20 are pre-existing and unrelated: path-separator and env-isolation issues on this Windows checkout.)

One note for your judgment: the run/adopt/config/prompt endpoints are protected, but /api/overview and /api/night/<ts> are read-only and gated by Host alone. Cross-origin reads are already blocked by the same-origin policy since no CORS headers are sent, and rebinding is blocked by the Host check, so I did not add a token requirement there — it would have complicated nothing but the initial page load. Happy to extend the token to reads as well if you would rather have defence in depth on the evidence and config payloads.

@Alphaxalchemy Alphaxalchemy changed the title feat(sleep): local control-panel dashboard (stacked on evidence chain) feat(sleep): local control-panel dashboard Aug 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a stdlib-only local “control panel” dashboard for SkillOpt-Sleep, exposing the per-night evidence chain and allowing controlled editing of config/prompts and launching/adopting runs via a loopback-bound HTTP server.

Changes:

  • Introduces skillopt_sleep.dashboard (ThreadingHTTPServer) with JSON APIs for overview/night inspection, config & prompt edits, run control, and adoption.
  • Adds a single-file dashboard.html UI that renders the pipeline stages, evidence chain, gate math, and editors for prompts/config.
  • Wires a new python -m skillopt_sleep dashboard CLI subcommand and adds a focused security test suite for the authorization boundary.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
tests/test_dashboard_security.py Adds extensive negative/positive tests for Host/Origin/token/content-type/body validation and CORS/preflight behavior.
skillopt_sleep/dashboard.py Implements the local HTTP server, request authorization, config/prompt mutation endpoints, run launcher, and night/adopt endpoints.
skillopt_sleep/dashboard.html Provides the in-browser UI for browsing nights/evidence and performing safe edits and actions.
skillopt_sleep/__main__.py Adds the dashboard CLI command to start the server.
Suppressed comments (1)

skillopt_sleep/dashboard.py:402

  • /api/adopt builds the staging directory from os.path.basename(body["ts"]), which still permits ".." and symlink escapes. A crafted ts can make the dashboard call adopt_staging() on a directory outside the staging root, potentially overwriting arbitrary live paths if that directory contains a compatible manifest.json. Validate ts and ensure the resolved path remains within staging_root(project) before adopting.
            ts = os.path.basename(str(body.get("ts", "")))
            d = os.path.join(staging_root(self.project), ts)
            if not os.path.isdir(d):
                self._json({"error": "unknown night"}, 404)
                return

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread skillopt_sleep/dashboard.html Outdated
Comment on lines +540 to +556
if(kind==="select"){
d.innerHTML = `<label>${esc(label)}</label><select data-k='${key}'>` +
opts.map(o=>`<option value='${esc(o)}' ${String(v)===o?"selected":""}>${esc(o||"(inherit)")}</option>`).join("") + "</select>";
} else {
d.innerHTML = `<label>${esc(label)}</label><input data-k='${key}' type='${kind==="number"?"text":"text"}' value='${esc(v??"")}'>`;
}
g.appendChild(d);
}
}
async function saveConfig(){
const updates = {};
document.querySelectorAll("#cfgGrid [data-k]").forEach(el=>{
let v = el.value;
if(v==="true") v = true; else if(v==="false") v = false;
else if(v!=="" && !isNaN(Number(v)) && el.closest(".cfgitem").querySelector("label").textContent.match(/weight|budget|fraction|hours|tasks|rollouts|factor|K$|chars/i)) v = Number(v);
updates[el.dataset.k] = v;
});
Comment thread skillopt_sleep/dashboard.py Outdated
Comment on lines +345 to +349
ts = os.path.basename(path[len("/api/night/"):])
d = os.path.join(staging_root(self.project), ts)
if not os.path.isdir(d):
self._json({"error": "unknown night"}, 404)
return
Comment on lines +155 to +170
with self.lock:
if self.running():
return {"ok": False, "error": "a run is already in progress"}
cfg = load_config(invoked_project=project)
self.log_path = os.path.join(cfg.state_dir, "dashboard-run.log")
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
self.mode = "dry-run" if dry_run else "run"
cmd = [sys.executable, "-m", "skillopt_sleep", self.mode,
"--project", project, "--progress"]
log = open(self.log_path, "w", encoding="utf-8")
no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
self.proc = subprocess.Popen(
cmd, stdout=log, stderr=subprocess.STDOUT,
creationflags=no_window, cwd=project or None,
)
return {"ok": True, "mode": self.mode}
Copilot AI review requested due to automatic review settings August 1, 2026 18:26
@Alphaxalchemy

Alphaxalchemy commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — all three are real, and all three are fixed in b3db5ef.

1. /api/night/<ts> and /api/adopt path traversal — confirmed and fixed.

You were right that os.path.basename is not containment. Confirming the behaviour:

ts='..'      basename='..'  -> /proj/.skillopt-sleep     (escaped)
ts='../..'   basename='..'  -> /proj/.skillopt-sleep     (escaped)

The adopt path was the worse of the two: it fed that directory straight to adopt_staging, which copies over the live SKILL.md and CLAUDE.md. Both endpoints now share one resolver, _night_dir, which percent-decodes first (so %2e%2e is normalised before validation rather than sneaking through as a literal), rejects anything that is not a plain single path component, and then confirms the resolved path is still under the staging root via commonpath.

That last step is what catches the symlink case you flagged. Verified with a directory junction, which is the unprivileged Windows equivalent:

junction: staging/sneaky -> /proj/elsewhere
os.path.isdir(link) -> True     # the old check passed
_night_dir(proj, 'sneaky') -> None   # rejected

Non-string ts values are rejected rather than stringified, too — {"ts": null} used to become the string "None".

2. _RunState.start() — fixed, with one correction.

The Popen failure half is exactly right: it raised straight out of the request-handler thread, dropping the connection and leaving the UI waiting on a response that never came. It now returns {ok: false, error} and the launcher stays usable.

On the handle: in CPython the local log is released by refcounting when start() returns, so it was not leaking in practice — but relying on refcounting for a file descriptor is not something to leave in place, and on Windows any delay in that release keeps the log locked. It is now closed explicitly via a with block after the child has inherited its dup. The test asserts this the only way that really proves it: it unlinks the log file, which Windows refuses while a handle is open.

3. Config editor typing — fixed, and pushed down to the server.

Confirmed, and the ternary was doing nothing at all:

type='${kind==="number"?"text":"text"}'

Fields now declare a kind (text / select / bool / int / float); rendering and parsing both key off that kind, so numeric fields render as type="number" with an appropriate step and label copy is just copy.

I also took your parenthetical — "load_config() does not type-coerce" — as the more important half, because the client is not the only possible caller of /api/config. The server now validates independently in _coerce_config_value, against the type of each built-in default in DEFAULTS, which is the only place that actually knows the schema. A value that cannot be coerced is a 400 and nothing is written, so a bad field cannot half-apply a form.

Fixing the blanket coercion also fixed a bug in the same line: if(v==="true") v = true applied to every field, so typing true into the free-text house-rules box wrote a boolean into config.json. That is now a regression test.


12 tests added (37 in the module, no skips): traversal and link-escape attempts across both endpoints — each asserting nothing was adopted, not just the status code — spawn-failure handling and log-handle release, and config typing including the partial-write and free-text cases.

Full suite: 287 tests, 20 failures, byte-identical to the same run against pristine main — no new failures.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (3)

skillopt_sleep/dashboard.py:236

  • _RunState.status() is intended to return a tail of the run log, but it reads only the first 200k bytes via _read_text() and then slices the last 6000 characters from that chunk. Once the log grows beyond 200k, the UI will stop showing the true end of the log (it will show the end of the first chunk instead).
    def status(self) -> Dict[str, Any]:
        tail = ""
        if self.log_path:
            text = _read_text(self.log_path)
            tail = text[-6000:]
        rc = None
        if self.proc is not None:
            rc = self.proc.poll()
        return {"running": self.running(), "returncode": rc,
                "mode": self.mode, "tail": tail}

skillopt_sleep/dashboard.py:124

  • _coerce_config_value() accepts float strings like "nan"/"inf" because float() parses them successfully. Persisting NaN/Infinity into config will propagate non-finite values into gate arithmetic and can produce confusing results or non-JSON-safe outputs in downstream reporting.
    if isinstance(default, float):
        try:
            return float(str(value).strip())
        except (TypeError, ValueError):
            raise ValueError(f"{key} must be a number") from None

skillopt_sleep/dashboard.py:94

  • The dashboard always writes to ~/.skillopt-sleep/config.json. If a user currently uses config.yaml/config.yml (supported by load_config()), saving from the dashboard will create/overwrite config.json and silently change precedence (config.json is loaded before YAML), potentially altering behavior in a hard-to-debug way.
def _user_config_file() -> str:
    return os.path.join(HOME_STATE_DIR, "config.json")

A zero-dependency (stdlib http.server) web UI over one project's sleep
pipeline, arranged to mirror the data flow: per-stage role/model/prompt
display with live prompt editing, a per-night evidentiary chain browser
over evidence.jsonl, the gate arithmetic spelled out, a config editor, a
run/dry-run launcher, and explicit adopt (nothing auto-adopts from the UI).

    python -m skillopt_sleep dashboard [--project DIR] [--port N]

Addresses the review on microsoft#152:

* Rebased onto current main; the evidence-chain commit landed as microsoft#151 and
  is dropped here. This commit is only the dashboard and its tests.

* Request authorization. Binding to 127.0.0.1 is not an authorization
  boundary — any page in the user's browser can POST cross-origin to
  loopback, and a hostile name resolving to loopback (DNS rebinding) makes
  those requests look local. Every request now passes through _authorize:

  - loopback Host is required on every request, GET included, with the
    port matching the bound port. This is what defeats rebinding: the
    browser sends the attacker's name, not ours.
  - state-changing POSTs additionally require a trusted Origin (missing
    and foreign are both rejected), an application/json content type, and
    an unguessable per-process capability token in a custom header.

  Each is independently sufficient to stop a cross-origin page: a form
  cannot set a custom header or a JSON content type without a preflight,
  which this server deliberately does not answer. No CORS headers are
  emitted anywhere, so foreign origins cannot read responses either.

  The token is minted per server process and substituted into the served
  HTML, so it never travels in a URL and restarting the dashboard
  invalidates every previously served page.

* Request bodies are validated, not coerced. An absent, empty, malformed,
  non-object or oversized body is now a specific 4xx instead of silently
  becoming {}. That coercion is what let a contentless cross-origin POST
  to /api/run start a real run. Oversized bodies are drained within a
  bound before the 413 so well-behaved clients read the status rather than
  a connection reset.

tests/test_dashboard_security.py adds 25 tests. Every negative case
asserts twice — that the request was refused, and that the side effect did
not happen, since a 403 that still started a run would pass a status-only
test. Covers foreign Host (all seven endpoints), wrong/absent port,
rebinding names, foreign/missing/null/lookalike Origins, form,
text/plain, multipart and absent content types, missing/wrong/
cross-process tokens, empty, malformed, non-object and oversized bodies,
absence of CORS headers, unanswered preflight, and the reported vector
verbatim (empty cross-origin form POST to /api/run). Same-origin JSON
happy paths for config, prompts, run and adopt are retained.
…rites

Addresses three review findings on the dashboard.

* Staging path containment. `os.path.basename` is not containment:
  `basename("..")` is `".."`, so `/api/night/..` resolved to the staging
  parent and `/api/adopt` would have handed that directory to
  adopt_staging, copying whatever it found over the live SKILL.md and
  CLAUDE.md. Both endpoints now share `_night_dir`, which percent-decodes
  first, rejects anything that is not a plain single path component, and
  then confirms the *resolved* path is still under the staging root — so a
  symlink or junction planted inside staging cannot redirect the read
  either. Non-string `ts` values are rejected rather than stringified.

* Run launcher resilience. A failing `subprocess.Popen` raised straight
  out of the request-handler thread, dropping the connection and leaving
  the UI waiting; it now returns {ok: false, error} and the launcher stays
  usable. The parent also closes its own copy of the log descriptor after
  spawning instead of relying on refcounting to release it, which on
  Windows kept the file locked.

* Config value typing. The editor declared fields as "number" but rendered
  every one as `type="text"` (a no-op ternary) and decided what to coerce
  by matching a regex against the *label text* — so renaming a label would
  silently start writing strings into config.json, and `load_config` does
  not type-coerce. Fields now declare a kind (text/select/bool/int/float),
  rendering and parsing both key off that kind, and the server validates
  independently in `_coerce_config_value` against the type of each built-in
  default, since the client is not the only possible caller. A value that
  cannot be coerced is a 400 and nothing is written, so a bad field cannot
  half-apply a form. Free text is no longer coerced: "true" in the house
  rules stays the string "true".

Adds 12 tests: traversal and link-escape attempts on both endpoints
(asserting nothing was adopted, with a junction fallback so the link case
runs on unprivileged Windows too), spawn-failure handling and log-handle
release, and config typing including the partial-write and free-text cases.
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.

3 participants