fix(native): type the config boundary instead of coercing it (#525) - #536
Conversation
`agentbox`'s config boundary ran values through Python constructors rather
than checking them, and the coercions went the wrong way on exactly the
fields where that is dangerous:
* `protectMemory: "false"` and `users.agent.root: "false"` both came out
True, because `bool("false")` is - the config configuring the opposite of
what the operator wrote, and for `root` that is the account the settings
page and the root vhost belong to.
* `agents: claude` became six one-character agents (`list("claude")`), and
a string `sudoAllowlist` the same way.
* An unknown top-level key was ignored, so a misspelled knob and an absent
one were indistinguishable.
* A wrong type that did not coerce escaped as an AttributeError or
ValueError traceback with no field name in it.
So declare every documented field once, in BOX_SCHEMA / USER_SCHEMA /
SESSION_SCHEMA, and check a value against its declared shape before Spec
reads anything out of it. Unknown keys are refused at every object and the
message names the keys that exist; every message carries the full path
(`users.robot.environment.PORT must be a string, got 8080`); a non-string
mapping key is refused rather than sorted against a string. Validation is
the first statement of `Spec.__init__`, which `cmd_apply` builds before
first_boot and before rendering, so nothing is written, no user is created
and no unit is touched after a config is refused.
The per-field isinstance guards that grew one at a time (#404, #431, #453)
are now what the schema does for every field, so they go - their messages
are unchanged, since the schema phrases them identically. `schemaVersion` is
accepted and pinned to 1, so a future incompatible change can say so instead
of half-rendering. `load_config` names the FILE for a parse error or a
non-mapping top level, in both dialects.
No rendered byte changes: the golden native fixture, one-spec-both-backends
and backend-parity are all unmoved.
Tests: ten new cases in tests/test_agentbox.py covering every example from
the issue, unknown keys at each boundary, malformed nesting (ConfigError,
not a traceback), null still meaning "not written", the schema version, and
that a refused config renders nothing. Plus a drift guard that reads every
`.get("field")` in Spec/User/user_seed out of the source and fails if the
schema does not declare it - an undeclared field is now a refused config,
not an ignored one.
Closes #525
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0183GHX69wCnET2cA2puwzfr
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe native configuration boundary now reports path-specific ChangesNative configuration validation
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to Native configuration is now strictly validated before rendering or host mutation, preventing malformed or misleading values from silently changing behavior. Existing valid configurations remain supported, with no identified merge-blocking risk. Sequence Diagram(s)sequenceDiagram
participant ConfigFile
participant load_config
participant ConfigSchema
participant Spec
participant Renderer
ConfigFile->>load_config: provide JSON or YAML
load_config->>ConfigSchema: validate parsed mapping
ConfigSchema-->>load_config: validated configuration or ConfigError
load_config->>Spec: pass validated values
Spec->>Renderer: render specification
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Linked Issues checkExplanation The changes address issue Full details: Docstring CoverageExplanation Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 1 files. (1 skipped: 1 unsupported.) ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Motivation
bin/agentbox's configuration boundary coerced values with Python constructors instead of validating the declared shape, so a malformedconfig.yamlwas accepted with different semantics from what the operator wrote. Reproduced at7c762f2against the golden native config:protectMemory: "false"bool("false")isTrue)users.agent.root: "false"agents: claudelist("claude"))sudoAllowlist: /bin/trueprotectMemry: falseusers: [agent]TypeError: list indices must be integersusers.robot.environment: "A=1"ValueError: dictionary update sequence element #0 has length 1users.robot.sessions: [main]AttributeError: 'list' object has no attribute 'items'The first two reverse explicit intent on a privileged setting, which is why this is a P1. The rest make an ordinary typo either invisible or a traceback with no field name in it.
What changed
bin/agentboxgains a closed, typed description of the config -BOX_SCHEMA,USER_SCHEMA,SESSION_SCHEMA- andSpec.__init__validates against it as its first statement, before it reads a single value:bool(...),list(...)ordict(...)as a validator.nullis still accepted wherever a value is, and still means "not written" (and for the booleans, off, asbool(None)always did) - so no existing config changes meaning.users.robot: unknown key 'agnet' (known keys: agent, environment, environmentFiles, home, root, seedMainSession, sessions, ttydPort).users.robot.environment.PORT must be a string, got 8080.sorted(),USER_REand a file name.schemaVersionaccepted and pinned to1, so a future incompatible change can say so instead of half-rendering. Absent means 1; nothing in the field carries the key.load_confignames the file for a parse error or a non-mapping top level, in both dialects (the JSON branch checked neither).isinstanceguards that grew one at a time (fix(native): one canonical guide per user, and point the agents at it #404, Native boxes: ship the webhook payloads and default the feature on (#425, second half) #431, feat(native): patch the base OS unattended, without a reboot #453) are gone - the schema does that for every field, and phrases the messages identically, so those errors are unchanged.Validation happens in
Spec, whichcmd_applybuilds beforefirst_bootand before rendering, so no file is written, no user created and no unit touched after a config is refused;main()already turnsConfigErrorinto one line on stderr and exit 2.Kept
Spec/Useras the decoded models rather than adding parallelBoxSpec/UserSpecdataclasses: they already are the normalized view the renderer reads, the issue's acceptance criteria are all about the boundary in front of them, and a second set of models would be a large refactor with no behavioural gain.User-visible and security effects
config.yamlcarries a typo'd key or a quoted boolean will fail to apply where it previously applied something else. Every config the deployment templates write (aws/lightsail-template.yaml,azure/agent-box.bicep) and both committed fixtures validate unchanged.root: "false"case is the one that mattered - a quoted boolean can no longer hand a user the settings page, the root vhost and the reboot grant against the operator's written intent.Checks run
python3 tests/test_agentbox.py- 95 tests, OK (85 before; the native fixture is unmoved)nix build -L .#checks.aarch64-linux.agentbox-render- OKnix build -L .#checks.aarch64-linux.one-spec-both-backends- OKnix build -L .#checks.aarch64-linux.backend-parity- OK (incl. negative control)nix build -L .#checks.aarch64-linux.runtime-profile- OK (this is the flake8 gate onbin/agentbox)nix build -L .#checks.aarch64-linux.module-generated-up-to-date- OKpython3 scripts/check_one_spec.py- PARTIAL natively, full under the flake check aboveNo AWS cost, IAM, networking or migration impact. No UI change, so no screenshots.
Regression coverage
ConfigSchemaTestintests/test_agentbox.py, ten cases: every example from the issue with its exact message, unknown keys at each of the six object boundaries, malformed nesting asserted asConfigErrorrather than a traceback,osUpdates: falsestill naming the spelling that turns it off,nullstill meaning "not written", the schema version, a non-mapping file naming the file, andapply --rootleaving the tree uncreated when the config is refused.Plus a drift guard: it reads every
.get("field")inSpec.__init__,User.__init__andRenderer.user_seedout of the source withastand fails if no schema declares it. Without it, adding a field to the renderer without declaring it would no longer be a quietly-ignored knob - it would be a refused config on every box that sets it. The receiver table is exhaustive, so a new local also fails the test until it is classified.Closes #525