feat(api): add scope-guarded profile write endpoints - #585
Conversation
The HTTP surface can list, read, and validate agent profiles but cannot
write them, so creating, editing, or deleting one still requires the CLI.
This adds the mutating half of the surface a management UI needs, plus an
authoring read that returns a profile exactly as stored.
Four routes:
POST /agents/profiles create from a supplied document
PUT /agents/profiles/{name} replace an existing local profile
DELETE /agents/profiles/{name} remove a local profile
GET /agents/profiles/{name}/source raw document, placeholders intact
Most of the diff sits below the handlers, because three of the four
contracts these routes need are properties of the service layer, and each
previously failed silently rather than loudly.
replace_profile in services/profile_store.py is update-only persistence.
write_profile(overwrite=True) is an upsert, so a PUT naming a built-in
would have written a local file that shadows a shipped profile on load,
which is the condition duplicated_in exists to report. replace_profile
raises ProfileNotFoundError instead, and because store_path resolves only
inside LOCAL_AGENT_STORE_DIR, that rejection happens at the service
boundary under the lock rather than in a handler pre-check.
locked_atomic_write gains must_exist, enforced in the same critical
section as overwrite. Checking existence outside the lock would be the
same TOCTOU shape that #543 removed from write_profile. The contradictory
pair overwrite=False with must_exist=True raises ValueError so a caller
bug fails fast instead of masquerading as FileExistsError.
A shared helper, _validate_profile_for_write, backs both POST and PUT so
the two cannot drift apart on either rule. It rejects any error-severity
finding with 400 and returns warnings to the caller rather than blocking
on them, and it requires the storage key and the frontmatter name to
agree: parsing treats the filename stem only as a fallback, so "name: foo"
stored as bar.md previously loaded as foo while being addressed as bar.
Every rejection carries one detail shape, {"message", "errors"}, so a
client iterates errors unconditionally instead of switching on the type of
detail. The helper parses the frontmatter once and calls
validate_frontmatter, rather than calling validate_profile_text and then
parsing a second time for the name check.
GET /agents/profiles/{name}/source exists because GET
/agents/profiles/{name} applies resolve_env_vars to the raw text before
parsing. An editor built on the resolved route would write substituted
values back, persisting a resolved secret from the managed environment
file into a plaintext profile.
DELETE requires cao:admin alone, matching six of the seven existing DELETE
routes; POST and PUT take cao:write or cao:admin like the other
non-destructive writes. No route is added to the scope-exemption set,
because these are real mutations, unlike the validate routes.
Also fixes a P3 reported on #575. Three malformed-but-parseable documents
raised TypeError out of profile_validator: an unhashable element in
allowedTools and a non-string role, both hashed against a set for
membership, and mixed-type mcpServers keys, compared while sorting schema
errors by path. TypeError is not caught by the route's ValueError handler,
so an endpoint whose job is reporting what is wrong with a document
answered some invalid documents with HTTP 500. All three now return 200
with valid: false and the schema error attached. The coverage gap was that
every malformed-input test used unparseable YAML and none used
parseable-but-wrong-typed.
Tests: 53 new across five files. Full suite 6,379 passed.
There was a problem hiding this comment.
I found two blocking defects: the new full-profile source read does not enforce OAuth scopes, and deletion does not use the lock required by the new update-only guarantee.
Correction: I previously described the read issue as normal agent behavior exposing secrets. That was too broad; the supported finding is the missing authorization check on the new HTTP source route.
The admin-only DELETE policy is internally consistent, but #510 still says DELETE accepts cao:write or cao:admin; please update the issue if admin-only is retained.
|
|
||
|
|
||
| @app.get("/agents/profiles/{name}/source") | ||
| async def get_agent_profile_source_endpoint(name: str) -> ProfileSourceResponse: |
There was a problem hiding this comment.
[P2] Apply a scope check to the new source read
This is an API authorization issue, not agent runtime behavior. The new /source route returns the complete stored profile document but has no require_any_scope dependency, so a request without a token still returns 200 when OAuth is enabled. Protect this authoring endpoint with write/admin, or at minimum the normal read scope. My earlier claim about normal agent behavior exposing API_TOKEN was too broad and has been removed.
| raise InvalidProfileNameError(f"Profile name '{name}' escapes the local store.") | ||
|
|
||
| try: | ||
| locked_atomic_write(target, content, overwrite=True, must_exist=True) |
There was a problem hiding this comment.
[P2] Use the same target lock when deleting
The must_exist check only protects this update if deletion also takes the same lock. delete_profile() still does an unlocked exists() followed by unlink(). I paused a replace after this check, let DELETE remove the file successfully, and then let the replace continue; os.replace() recreated the profile and both operations reported success. The new concurrency test deletes the file before starting two writers, so it does not exercise this race. Put the existence check and unlink under the same per-target lock as create and replace.
fanhongy
left a comment
There was a problem hiding this comment.
Summary
The write validation and focused tests are generally thorough, but the new surface has two blocking defects: raw profile source remains readable without authentication when OAuth is enabled, and DELETE does not participate in the lock on which PUT's update-only guarantee depends.
Findings
P1 - Require authentication on the raw source endpoint
src/cli_agent_orchestrator/api/main.py:2160
GET /agents/profiles/{name}/source has no require_any_scope(...) dependency. Authentication in this service is route-dependency based, so enabling OAuth does not protect this endpoint. I reproduced this with OAuth enabled, no Authorization header, and a raw malformed profile containing a confidential marker: the endpoint returned 200 and the marker. This is broader than the parsed profile path because _read_agent_profile_source returns exact content from local, provider, and extra stores even when parsing would fail.
Add a read gate such as Depends(require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN)), plus tests that a missing token is rejected and a read-scoped token is admitted.
P1 - Serialize DELETE with the lock used by update-only PUT
src/cli_agent_orchestrator/services/profile_store.py:182
replace_profile checks must_exist while holding the target lock, but delete_profile performs its exists()/unlink() at lines 210-212 without that lock. A DELETE can therefore unlink after PUT's existence check and before PUT's os.replace; PUT recreates the file and both requests report success. A deterministic barrier reproduction produced delete=success, put=success, and a surviving file containing the replacement. The added concurrency test deletes before starting its writers, so it does not exercise a concurrent deleter.
Make deletion acquire the same target lock for its existence check and unlink, preferably through a public atomic-delete helper, and add a barrier test that overlaps DELETE with PUT.
P2 - Match DELETE authorization to the issue contract
src/cli_agent_orchestrator/api/main.py:2132
Issue #510 specifies that profile deletion accepts cao:write or cao:admin, but this route requires admin only; the new test at test/api/test_scope_coverage.py:225 explicitly locks in a 403 for a write token. A client provisioned with the documented profile-management write scope can create and edit profiles but cannot complete the required delete workflow.
Use require_any_scope(SCOPE_WRITE, SCOPE_ADMIN), or formally revise the issue contract and affected client expectations before retaining the stricter policy.
P3 - Export the new public store operation
src/cli_agent_orchestrator/services/profile_store.py:143
replace_profile is a new public service function, but the module's __all__ list at lines 36-43 still exports every peer operation except this one. Explicit imports happen to work, while wildcard/public-surface consumers omit the update operation.
Add "replace_profile" to __all__.
Validation
- Re-ran all five changed test modules: 170 passed, 3 dependency deprecation warnings.
- Reproduced the PUT/DELETE race deterministically; both operations succeeded and the deleted profile was recreated.
- Reproduced the auth bypass with OAuth enabled and no bearer token; raw content returned with HTTP 200.
git diff --checkpassed, and the checkout remained clean at the reviewed SHA.- Acquisition metadata reports all 24 GitHub checks successful.
Addresses the review findings on #585 from @haofeif and @fanhongy, who reviewed independently and converged on the same two blocking defects. GET /agents/profiles/{name}/source carried no scope dependency, so enabling OAuth did not protect it. Authorization here is route-dependency based, and the route now takes require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), the same shape the ten already-guarded GET routes use. The pre-existing profile reads beside it stay ungated, following the split the #505 review settled: gate the newly added read, leave shipped routes alone rather than risk breaking an existing unauthenticated reader. Gating matters more on this route than on those siblings because _read_agent_profile_source returns the stored bytes verbatim across the local, provider, extra and built-in stores, including documents that fail to parse, whereas the parsed route can only return what the model accepts. test_scope_coverage.py could not have caught this: _MUTATING_METHODS is {POST, PUT, PATCH, DELETE}, so a new ungated GET is invisible to it. The guard therefore ships with a structural test asserting the dependency exists on the route object, mirroring _NEW_505_READ_ROUTES, plus enforcement tests that a scopeless token is refused and a read-scoped token is admitted. A status-code test would prove nothing, because auth is default-off and require_any_scope returns the full scope set when it is off. delete_profile performed an unlocked exists() then unlink(), which voided the update-only guarantee replace_profile advertises. A delete could land between an update's must_exist check and its os.replace: the delete succeeded, the update republished, and both callers were told they succeeded while a deleted profile was back on disk holding the replacement text. Reproduced deterministically with a barrier. locked_atomic_delete moves the existence check and the unlink inside the same per-target lock the writers use; unlink is already atomic, so the helper adds only the lock. It is safe against the flock-is-per-inode hazard because the lock file is not the target: _lock_path_for keys a file under LOCK_DIR by a hash of the resolved path and those are never unlinked. Two of these were self-inflicted in ways the tests did not catch. The replace_profile docstring described the update-versus-delete hazard accurately and then left deletion as the unlocked side of the pair, so it promised a guarantee the code did not deliver. The concurrency test was named for a concurrent deleter but deleted the file before its barrier and raced two writers, so it never overlapped a delete with a write. The test is renamed to what it exercises and a deterministic DELETE-versus-PUT overlap test is added alongside it. DELETE /agents/profiles/{name} moves from SCOPE_ADMIN alone to SCOPE_WRITE or SCOPE_ADMIN. Scopes are a flat set rather than a hierarchy: require_any_scope tests membership and get_current_scopes returns the token's claims unexpanded, so admin-only does not merely add admin access, it refuses a client holding exactly cao:write. That contradicted the contract published in #510 and would have left a profile-management credential able to create and edit a profile but not remove it. The six-of-seven precedent for admin-only DELETE still holds, but every one of those routes removes running or generated state, while the lone write-or-admin exception is the only content resource among them. A profile is an authored document, so it belongs with that one. Also exports replace_profile from profile_store's __all__, which listed every peer operation except the one this PR added. Tests: 10 new across three files. Full suite 6,389 passed.
fanhongy
left a comment
There was a problem hiding this comment.
Summary
There are no changes after the prior reviewed head; the checkout remains at 167d46ce619d65d65f2018fd752858bc60f911f3. The latest commit correctly gates the source route and serializes deletes with writes. I found one P2 in the aggregate write path and one P3 test-scope nit.
Findings
P2: Write validation can persist profiles that the runtime cannot load
src/cli_agent_orchestrator/api/main.py:1998
_validate_profile_for_write treats an empty JSON-Schema finding list as sufficient and the POST/PUT handlers then persist the document. However, the request contains YAML, whose mappings may have non-string keys, while JSON Schema assumes object keys are strings. For example, both of these documents produce no validator errors:
mcpServers:
1:
command: echotoolAliases:
1: ReadI reproduced both through POST /agents/profiles: each returned 201 and created the file, but parse_agent_profile_text then raised Pydantic ValidationError at mcpServers.1.[key] or toolAliases.1.[key]. The profile therefore appears to save successfully but cannot be read or launched, contradicting the route's guarantee that invalid profiles never reach disk. The schema limitation predates this PR, but making it the sole gate before the new persistence operation introduces the broken save path.
Validate the parsed metadata with the same AgentProfile model/load semantics before writing, or explicitly reject non-string YAML mapping keys for model fields that require string keys. Add an endpoint regression asserting these requests return 400 and leave no file.
P3: Rejection-shape test claims broader coverage than it provides
test/api/test_api_profile_surface.py:757
TestWriteRejectionShape says it covers “Every 400 from a write route,” but its loop only sends POST requests and only exercises validation-related failures. DELETE's unsafe-name path at src/cli_agent_orchestrator/api/main.py:2161 intentionally returns a bare-string detail, so the stated suite-wide contract is false even though the tested POST behavior is correct. Rename and document this as the POST/PUT validation-error shape, or parameterize the relevant POST and PUT cases and explicitly exclude service/DELETE errors.
Validation
- Confirmed the clean detached checkout and head SHA; there are zero commits after the prior reviewed SHA.
- Read the acquisition metadata, full diff, commit analysis, and context report, then inspected the changed implementation, callers, schema/model, and tests.
uv run --frozen python -m pytest -q test/api/test_api_profile_surface.py test/api/test_scope_coverage.py test/services/test_profile_store.py test/services/test_profile_validator.py test/utils/test_atomic_file.py: 180 passed, 3 dependency deprecation warnings.- Custom HTTP reproduction: both non-string-key profiles returned
201, persisted, and failedparse_agent_profile_textwith Pydantic key validation errors. git diff --check 135e7ff865226955ad67059181b4ea5939c9ae6e..HEAD: passed.- Acquisition also reports all 24 GitHub checks successful. Current
origin/mainstill produces a merge conflict inapi/main.py; the eventual conflict resolution will need separate validation.
Addresses the round-2 findings from @fanhongy on #585. A profile is submitted as YAML, which allows any scalar as a mapping key, but the format is described by JSON Schema, where object keys are strings by definition. jsonschema therefore reported nothing wrong with mcpServers: 1: command: echo so the write returned 201 and created the file, and parse_agent_profile_text then refused to load it with a Pydantic error at mcpServers.1.[key]. The profile saved and could not be read or launched, contradicting the route's guarantee that an invalid profile never reaches disk. Reproduced for both mcpServers and toolAliases before fixing. validate_frontmatter now walks the parsed document and reports any non-string mapping key as an error. Placed in the validator rather than only on the HTTP write path so every consumer agrees: otherwise cao profile validate and POST /agents/profiles/validate would call such a document valid while the write routes rejected it, and a UI that validates before saving would show a contradiction. Checking the key type generally rather than enumerating fields also covers YAML's other auto-typing: an unquoted 2026-01-01 key becomes a datetime.date, which fails the same way and is now caught. The schema limitation predates this PR. What this PR introduced was making that schema the sole gate in front of a new persistence operation, which turned a latent gap into a broken save path. Not fixed by validating through the AgentProfile model, which would catch strictly more. The write path persists unresolved text, so model-validating unresolved content would reject provider_init_timeout: ${TIMEOUT} that the runtime accepts after resolution, while model-validating resolved content would make acceptance depend on the server's environment. That tradeoff needs its own design rather than riding along here. Also unifies the 400 detail shape across the whole profile surface. The {"message", "errors"} dict was previously produced only inside _validate_profile_for_write, while four sites still returned a bare string: the service-raised InvalidProfileNameError on POST, PUT and DELETE, and the source route's ValueError. A caller therefore still had to switch on type(detail), which is what unifying the shape was supposed to remove, and DELETE was the reachable one because it has no body to validate first. The shape moves to a module-level _profile_write_rejection and all four sites use it. 404 and 409 keep FastAPI's conventional bare string: the status code discriminates and there are no findings to attach. TestWriteRejectionShape claimed to cover "every 400 from a write route" while its loop only sent POST and only validation failures, so the contract it documented was false. It is now parameterized across POST, PUT and DELETE, including the service-raised name error, so the gap fails a test instead of merely contradicting a docstring. This is the third assertion in this PR that promised more than the code delivered, after the replace_profile docstring and the concurrency test named for a deleter it never exercised. Tests: 13 new. Full suite 6,402 passed.
Brings the branch up to c64c9fa, three commits on since this PR's base: #526 (durable workflow run journal), #545 (codex handoff extraction), and #539 (claude_code startup prompt). One conflict, in api/main.py's typing import: this branch added Sequence for the write-rejection helper and #526 added AsyncIterator for its SSE events route. Resolved to the union of both, then reformatted by black. docs/api.md and test/api/test_scope_coverage.py auto-merged. Merged rather than rebased so the two review-cycle commits keep their reviewed SHAs and no force-push is needed. Verified on the merged tree: 216 passed across the five modules this PR touches, test_scope_coverage.py fully green at 33 passed, and black and isort clean over 548 files. Worth noting for anyone following the review thread: the ten test_scope_coverage failures that appeared locally against 0903561 are fixed upstream by c64c9fa and no longer reproduce on pristine main.
|
Thanks, both confirmed and fixed. I reproduced each before changing anything. P2, write validation can persist profiles the runtime cannot loadConfirmed exactly as described. Both shapes produce zero validator findings, The mismatch is format-level: the body is YAML, which allows any scalar as a
It lives in the validator, not only on the write path. Otherwise It checks the key type generally rather than enumerating the object-valued On your first suggestion, validating the parsed metadata through Endpoint regressions added as you asked: 400 with nothing on disk for P3, rejection-shape testCorrect, and worse than one route. Enumerating every 400 across the four routes Only the validation path produced the dict. So the docstring was false in four I took the other branch of your suggestion and made the claim true rather than Worth naming the pattern rather than only the instance: this is the third Merge, since you flagged it needs separate validationMerged Verified on the merged tree:
One aside in case you hit it too: 71 net new tests, rebaselined on |
There was a problem hiding this comment.
Correction after consistency re-check: the earlier source-route authorization and PUT/DELETE race findings are fixed. Two P2 issues remain: the write gate rejects URL-based MCP entries that CAO explicitly supports, and the new recursive YAML-key check can do exponentially growing work on the unauthenticated validation endpoint. I have reclassified the typed-placeholder case as a non-blocking, pre-existing validator limitation rather than a new P2 in this PR.
| except Exception as exc: | ||
| _reject(f"Profile could not be parsed and was not written: {exc}") | ||
|
|
||
| findings = validate_frontmatter(parsed.metadata) |
There was a problem hiding this comment.
[P2] Accept the URL-based MCP servers CAO already supports
This new hard gate rejects a profile containing a standard remote MCP entry such as docs: {type: http, url: https://mcp.example.invalid/mcp}. parse_agent_profile_text accepts it, and resolve_mcp_server_config explicitly preserves commandless URL/transport entries, but this schema check reports 'command' is a required property, so both POST and PUT return 400. This prevents users from creating or editing profiles for a supported MCP form. Please allow URL/SSE entries as well as command-based entries before making this schema a blocking write check.
| findings = validate_frontmatter(parsed.metadata) | ||
|
|
||
| errors = [f for f in findings if f.severity == "error"] | ||
| if errors: |
There was a problem hiding this comment.
Correction — non-blocking follow-up
The behavior above is reproducible, but I should not have classified it as a new P2 in this PR. The shared validator already rejects placeholders in typed schema fields, this limitation was explicitly disclosed before this re-review, and typed placeholders are not a documented profile form. Placeholder-aware validation is worth defining separately, but it should not block this write-endpoint PR.
| elif isinstance(value, list): | ||
| for index, child in enumerate(value): | ||
| child_path = f"{path}.{index}" if path else str(index) | ||
| findings.extend(_non_string_key_findings(child, child_path, _depth + 1)) |
There was a problem hiding this comment.
[P2] Bound traversal of repeated YAML aliases
The depth limit does not limit the total work here because yaml.safe_load represents every alias as another reference to the same Python object, and this recursion walks that object again for every reference. A 529-byte doubling alias chain at depth 20 took about 1.5 seconds in this function, with each extra level roughly doubling the time. Since the unauthenticated async /agents/profiles/validate route calls this synchronous traversal directly, small requests can stall the server for an unbounded time. Please track already-visited container identities, reject repeated/cyclic aliases, or enforce a global node budget rather than relying only on depth.
Two findings from @haofeif on the write gate, in opposite directions: it could be stalled by a valid document, and it rejected a valid one. The non-string mapping key check added last round walked the parsed document with only a recursion depth cap. That bounded the wrong dimension. yaml.safe_load resolves every alias to another reference to the *same* object, so N chained anchors that each reference the previous one twice leave memory linear while an unmemoized walk traverses the graph 2**N times. Depth was never the problem; revisiting shared objects was. A 640-byte, schema-valid body took ~1s locally and doubled per added level, against ~0s for the jsonschema step beside it, so the amplification was introduced entirely by that walk. It was reachable without credentials. POST /agents/profiles/validate is in the scope-exemption set, so it answers even when OAuth is configured, and it is declared async, so a synchronous CPU-bound walk on its thread stalls the event loop for every other request rather than only the caller's own. The walk now skips any container it has already visited, keyed on id(). That removes the amplification at its source and costs no coverage: a shared subtree cannot hold a different set of keys on a second visit, so one finding per offending key is the correct output, reported at the first path reaching it. Comparing identity is sound here specifically because every value stays reachable from the document for the duration of the walk, so nothing can be collected and no id recycled midway; the code says so rather than leaving it as a trap for a later reader. Identity memoization does not bound a document that is merely enormous, so explicit ceilings on total values and nesting depth remain, both ~1000x the largest bundled profile. Exceeding either now yields an error finding. Previously the depth cap returned silently, which reported an unchecked document as valid. Separately, agent_profile.schema.json required "command" on every mcpServers entry, while resolve_mcp_server_config documents command-less entries shaped {"type": "http", "url": ...} as passing through untouched, and providers forward them to their own MCP config. Because this PR made that schema the blocking gate in front of persistence, an incomplete description became a broken save path: POST and PUT returned 400 for a form CAO supports. Entries now require command or url via anyOf, with url declared so the field is described rather than merely tolerated by an absent additionalProperties: false, and so a wrong type is a finding. An entry defining neither is still rejected. That is the mirror image of the round-1 finding on the same decision: that one let unloadable profiles reach disk, this one blocked loadable ones. Both came from treating an incomplete schema as a blocking gate. The anyOf rejection message is jsonschema's generic "is not valid under any of the given schemas". It names the exact entry and path, but not which key is missing. Left as is rather than adding message-rewriting machinery to the validator; noted in the PR's known gaps. Tests: 20 new. A 40-level bomb (2**40 paths, under 1500 bytes) now validates in ~0.0001s, and a bad key inside a shared subtree is asserted to appear exactly once, which pins the memoization without depending on a clock. Legitimate anchor reuse still validates clean, both ceilings are asserted to reject rather than fall silent, and a url-based profile is asserted to survive the write, the profile parse, and MCP resolution with its transport intact. Full suite 6,711 passed.
Brings in #604 (flow frontmatter injection), #606 (read scope on sensitive read endpoints) and #613 (agent-step terminal cleanup). One conflict, in the signature of GET /agents/profiles/{name}. #606 added a require_any_scope dependency there; this branch had added a docstring note pointing editors at the source route. Resolved to both. #606 also settles a question this branch had answered the other way. Round 1 of review gated the new source route while deliberately leaving the already-shipped profile reads beside it ungated, on the #505 precedent that tightening a shipped route could break an existing unauthenticated reader. #606 has now gated those siblings, so that asymmetry is gone and the rationale recorded on the route no longer describes the code. Rewritten to say what is now true, and to point at the registry below. GET /agents/profiles/{name}/source is added to #606's _GATED_ROUTES and its sample requests, so it is covered by that file's enforcement tests rather than only by this branch's structural one. It belongs there on #606's own definition: it is a sensitive read, arguably more so than the parsed route beside it, since it returns stored bytes verbatim from the local, provider, extra and built-in stores, including documents that fail to parse. That list is maintained by hand, by design, so a new route does not join it automatically. Verified at this commit, with the worktree's own source on PYTHONPATH so the checkout under test is the one being imported: 300 passed across the profile, atomic, scope and read-gating modules, no failures.
A strawman of the previous commit found that its headline claim was only half true. It closed a CPU amplification in this module's own key walk and left a larger allocation vector open on the same unauthenticated route. jsonschema builds every error message eagerly, interpolating repr of the offending instance. YAML aliases resolve to repeated references to one object, so a document whose expansion is exponential in its byte count produces an error message that is too: a 651-byte body with 20 anchor levels that trips a single type error yielded a 25 MB message, 101 MB at 22 levels, doubling per level, which puts ~26 levels in the gigabytes. That string is then serialised into the response. Allocation is the ceiling, not CPU, and no care taken in this module's own traversal avoids it. Confirmed pre-existing rather than introduced here: byte-identical numbers on pristine origin/main, where the route was already scope-exempt and already async. Fixed here anyway. It is the same route and the same class of bug as the finding it sits behind, and shipping "the traversal is bounded" while a larger vector remains on that endpoint would be the same overstatement this PR has now corrected four times. _structural_bound_finding counts the values a fully expanded rendering would contain, memoized on id() so the count stays linear in distinct objects and capped so an enormous document costs no more to reject than a borderline one. It runs before the key walk and before jsonschema, and validate_frontmatter returns as soon as it reports, because continuing would pay exactly the cost the ceiling exists to avoid. Expressing the ceiling as expanded size rather than traversal steps also fixes a message that lied. The previous commit decremented a budget per edge traversed while naming it _MAX_WALK_VALUES and reporting "holds more than 20000 values", so an 84 KB document holding four values, one of them aliased 21,000 times, was rejected for holding twenty thousand. It now reports what is actually counted: that document does expand to ~21,004 values. Reachable inside the 256 KB content cap, so not hypothetical. The walk loses its own size budget as redundant. With identity memoization it is linear in the document's distinct containers, and it now only runs on documents the ceiling accepted. Two ratios stated separately, because they are not the same: against the largest bundled profile's 23 expanded values and depth of 3, the ceilings sit ~870x and ~21x above it. The previous commit's comment said "~1000x" of both, which was wrong by ~50x for depth. Also documents, rather than leaves implicit, that the two halves of validate_frontmatter report shared values differently. A shared value that is schema-invalid yields one finding per referencing path, since jsonschema does not memoize; a shared non-string key yields exactly one. Both are right, but a client rendering findings should not assume one convention. Tests: the anchor bomb is now rejected rather than accepted, so the assertions become deterministic. Rejection is asserted on the error message and on response size (under 2 KB for a body that previously returned 25 MB) rather than on elapsed time, which means a regression fails instead of hanging until CI's job timeout, as the earlier timing-only assertions would have. The memoization proof moves to 10 anchor levels, 1024 paths to one bad key, which stays under the ceiling so the walk still runs and one finding still proves dedup. 98 net new tests. Full suite 6,795 passed.
Brings in #608 (bearer token on the terminal WebSocket), #622 (replace a vulnerable image-size dependency) and #596 (xAI Grok CLI provider). No conflicts, despite #596 touching four files this branch also changes. Its schema addition, grokNativeWorkflows, sits well below the mcpServers block edited here, and its two new endpoint tests land in classes above the ones this branch added, so both sides applied cleanly. Checked rather than assumed, since a clean auto-merge is not the same as a correct one: - The mcpServers command-or-url anyOf and its url property both survive, and the incoming grokNativeWorkflows property is present alongside them. - The schema/model parity test from #575 still passes, which it would not if only one side of #596's field had landed. - #596's own profile validates clean through the expansion ceiling added here, and its assertion on the exact shape of the schema endpoint response still holds. - The ratios documented on that ceiling are unchanged. They are stated against the largest bundled profile, so a new provider shipping a profile would have invalidated them; #596 ships none, and developer.md is still the largest at 23 expanded values and depth 3. - docs/agent-profile.md took both descriptions without duplication. The local uv.lock drift that every commit on this branch has excluded had to be stashed to take the merge, because #596 adds psutil and types-psutil to that file. Those additions survive in the working tree; the drift itself is regenerated by every `uv run`, which is why it keeps reappearing, and it still does not belong in this branch. Verified on the merged tree: 347 passed across the profile, atomic, scope and read-gating modules, and 6,939 passed on the full suite. black and isort clean across 554 files. 98 net new tests, re-measured per file against this base.
P2a: URL-based MCP entries@haofeif , You were right, and the codebase documented the contradiction itself. Your exact example now validates and survives the round trip:
Two limits worth naming rather than leaving you to find:
The rejection message for an entry with neither key is jsonschema's generic P2b: bounded traversal, and the vector behind itYour mechanism was exactly right and I reproduced it independently before changing anything: 640 bytes at 20 levels, ~1s, doubling per level, against ~0s for the jsonschema step beside it. Close enough to your 529 bytes / 1.5s to be the same thing. The depth cap bounded the wrong dimension. Depth was never the problem, revisiting shared objects was. Of your three suggested remedies I took the first, tracking visited container identities, and added a global budget. I deliberately did not reject repeated or cyclic aliases. Anchor reuse is ordinary YAML and rejecting it would break documents that are fine: Both are pinned by tests so a later "fix" can't satisfy the bound by refusing anchors. Then I strawmanned that fix and found the larger half. jsonschema builds every error message eagerly, interpolating
2x per level, so ~26 levels reaches gigabytes, and that string gets serialised into the response. Allocation is the ceiling rather than CPU, and nothing done inside my traversal avoids it. On the 22-level document the identity-memoized walk costs 0.00003s while This one is pre-existing, not introduced by this PR. Verified against current The route was already scope-exempt and already I fixed it here anyway. It's the same route and the same class of bug as the finding it sits behind, and telling you "the traversal is bounded" while a larger vector remained on that endpoint would have been half an answer. If you'd rather it went to its own issue against The final shape is one ceiling on expanded size, ahead of both the walk and the schema step, memoized on Milliseconds, and flat as levels grow. Two corrections to my own first attempt at thisBoth were in the commit that fixed your finding, so they'd have landed under your name if you hadn't looked twice. The budget decremented per edge traversed while being named The comment called both ceilings "~1000x" the largest bundled profile. Against its 23 expanded values and depth of 3 they are ~870x and ~21x. Wrong by roughly 50x for depth. Stated separately now. I also documented something I'd left implicit: the two halves of Tests for all of this are deterministic rather than timed. The bomb is now rejected rather than traversed quickly, so the assertions are on the error message and on response size, under 2 KB for a body that previously returned 25 MB. If the memoization regresses, a test fails instead of hanging to the job timeout, which is what the earlier timing-only assertions would have done. P2cNoted, and thanks for revisiting it. No action taken. The Known gaps entry on placeholder-aware validation stays as it was, and I agree it wants its own definition rather than riding along here. One interaction with #606#606 gated I also added Verification
The PR description is rewritten with a Review round 4 section carrying the measurements above. |
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed current head 8bda8ec06ed987a855d768d340bae43ac4bf5f1f from scratch against current main. The source-read scope gate, DELETE/PUT serialization, and URL/SSE MCP round-trip fixes now hold. Two blocking gaps remain in the new structural guard, detailed inline: scalar aliases can still amplify a small unauthenticated validation request into gigabytes of schema output, and cyclic YAML is accepted and persisted even though the provider JSON path cannot serialize it.
| def expanded(value: object, depth: int) -> int: | ||
| nonlocal too_deep | ||
| if not isinstance(value, (dict, list)): | ||
| return 1 |
There was a problem hiding this comment.
[P1] Bound rendered scalar bytes, not only value occurrences
Every scalar returns 1 here, but the downstream cost this guard is meant to bound is repr(instance), which includes the scalar bytes again for every alias. On this head, a 22,136-character profile containing one 2,048-character &s scalar and 5,000 aliases to it in a schema-invalid toolsSettings list passes _structural_bound_finding; unauthenticated POST /agents/profiles/validate returns a 10,260,109-byte response. More importantly, a 250,088-character request (below the 262,144 cap) with a 190,000-character scalar and 15,000 aliases is also accepted at roughly 15,006 counted values, while the one jsonschema instance representation has a 2.85 GB lower bound. Because this route is scope-exempt and runs synchronously inside an async handler, one request can still exhaust process memory and block every request. Please include scalar byte length in the expansion budget, or otherwise bound/sanitize schema error rendering before it is serialized.
| identity = id(value) | ||
| if identity in memo: | ||
| return memo[identity] | ||
| memo[identity] = 1 # Cycle guard, in force while this container counts. |
There was a problem hiding this comment.
[P3] Reject cyclic graphs instead of counting a back-edge as finite
The provisional memo entry makes a self-reference contribute 1, and the new regression test consequently treats this document as valid:
name: cyc
description: cyclic
toolsSettings: &c {self: *c}POST /agents/profiles returns 201 and persists it, but the normal Kiro materialization path passes profile.toolsSettings into KiroAgentConfig and model_dump_json() at install_service.py:379, which raises PydanticSerializationError: Circular reference detected (id repeated). The write gate therefore still succeeds for a profile the runtime cannot materialize. A cycle also has no finite fully-expanded size. Please track in-progress identities separately from completed memo entries and return a validation error on a back-edge, while retaining memoization for ordinary shared acyclic subtrees.
|
Severity correction after impact/likelihood triage: the scalar-alias amplification is P1 because one unauthenticated, under-limit request can force multi-gigabyte allocation and take down the server. The cyclic-YAML finding is P3 / non-blocking because it requires deliberately self-referential input and affects only that profile. The Changes Requested verdict remains because the P1 is independently blocking. |
What
The write half of the profile HTTP surface for #510, plus the service changes it
needs and a fix for the P3 finding on #575.
No UI. That is the next and final PR on this issue.
This closes four of the five contracts @haofeif asked for on #510 (points 1, 2,
3 and 5) and the P3 comment from #575. Point 4, schema completeness, landed in
#575 with a disclosed remainder repeated under Known gaps below.
Review round 1
@haofeif and @fanhongy independently reviewed the first revision and converged on
the same two blocking defects. All four findings are fixed in follow-up commits on
this branch. I reproduced each one before fixing it rather than taking it on
trust, and two turned out worse than reported.
GET /{name}/sourcehad no scope gaterequire_any_scope(READ, WRITE, ADMIN)+ structural and enforcement testsdelete_profiledid not take the write locklocked_atomic_delete; deletion moved under the same lockDELETEscope contradicted #510WRITE, ADMINreplace_profilemissing from__all__Two things I want to surface rather than bury, because both are mine and neither
was caught by the tests I wrote:
The
replace_profiledocstring asserted that enforcing existence inside the lockcloses the update-versus-delete window. It described the hazard accurately and
then left
delete_profileunlocked, so the hazard simply moved to the other sideof the pair. A docstring promising a guarantee the code does not deliver is worse
than an undocumented gap.
The concurrency test was named
test_replace_profile_lets_exactly_one_concurrent_deleter_or_writer_win, but itsbody deleted the file before the barrier and then raced two writers. It never
overlapped a delete with a write, so the name claimed coverage that did not
exist. @fanhongy caught exactly this.
The scope reversal is discussed in full under Scope guards. Short version: I
justified admin-only on a six-of-seven precedent, but scopes are a flat set, so
admin-only 403s a
cao:writeclient rather than merely adding admin access, and#510 already published write-or-admin as the contract.
Why
#575 gave the profile surface a shared validator and a read-only validate route.
Nothing can yet create, edit or delete a profile over HTTP, so the Web UI still
cannot manage profiles at all, which is what #510 is for.
Doing that safely needs more than three route handlers. Three of the four write
contracts are properties of the service layer, not the HTTP layer, and getting
them wrong is silent rather than loud.
What changed
replace_profileinservices/profile_store.py, the update-onlycounterpart to
write_profile.This is the substantive one.
write_profile(..., overwrite=True)is an upsert,which is the wrong primitive for a
PUT. A request naming a built-in orprovider-managed profile would not fail: it would create a new local-store file
that shadows the original, silently changing which profile wins on load. That is
exactly the condition
duplicated_inwas added to surface in #523, so an upsertwould manufacture the thing we warn about.
Only one function is added.
write_profile(..., overwrite=False)is alreadycreate-with-409, and #543 documented it as the one supported way to ask for
create-without-clobber, so a
create_profilealias would be churn.must_existonlocked_atomic_write, enforced inside the same criticalsection as
overwrite:Same reasoning as the
overwriterace we fixed in #543. A caller testing for thefile beforehand would leave a window where a concurrent delete turns an intended
update back into a create.
overwrite=Falsewithmust_exist=Trueiscontradictory and raises
ValueErrorrather than always surfacing asFileExistsError, which would mislead the caller into thinking the file was inthe way.
locked_atomic_delete, also inatomic_file.py. Added during review, and itis the other half of the guarantee above rather than a nicety.
must_existisonly meaningful if deletion takes the same lock.
delete_profilewas doing anunlocked
exists()thenunlink(), so this interleaving was reachable:replace_profiletakes the lock and passes itsmust_existcheckdelete_profileunlinks the file and reports successreplace_profilepublishes, recreating what was just deletedBoth callers were told they succeeded and the deleted profile was back on disk
holding the replacement text. Reproduced deterministically with a barrier, and
the reproduction now shows the deleter blocked on the lock instead.
unlinkisalready atomic, so the helper adds only the lock, not atomicity. It is safe
against the
flock-is-per-inode hazard because the lock file is not the target:_lock_path_forkeys a file underLOCK_DIRby a hash of the resolved path, andthose are never unlinked, so removing a target leaves the lock inode intact.
The docstring I shipped on
replace_profileclaimed the enclosing guaranteewhile
delete_profilewas still the unlocked side of it, and the concurrencytest was named for a deleter it never exercised. Both are corrected.
Built-in protection falls out of the service boundary, which is what point 3
asked for.
profile_storeresolves only insideLOCAL_AGENT_STORE_DIR, so abuilt-in's name is simply not there, and
must_existtherefore rejectsPUTagainst a built-in under the lock. No handler-level check, no separate list of
protected names.
A shared
_validate_profile_for_writehelper inapi/main.py, used by bothPOSTandPUTso the two cannot drift apart. It runs the #575 validator on theexact document being persisted, rejects error-severity findings with 400, and
returns warnings for the response. It also enforces the name rule (below).
Two details in it are deliberate rather than incidental.
It parses the frontmatter once. The obvious implementation calls
validate_profile_text(content), but that parses internally and the helper needsthe metadata anyway for the name check, so the document would be parsed twice.
validate_profile_text's docstring exists specifically to stop callersduplicating that parse, so the helper parses once and calls
validate_frontmatter(metadata)instead.Every 400 from a write route uses one
detailshape:{"message": "...", "errors": [{"severity": "error", "message": "...", "path": "engine"}]}errorsis empty for failures that are not attributable to a field, such asunparseable YAML, but the key is always present so a client can iterate it
unconditionally. Without this, one endpoint returned a dict for a schema failure
and a bare string for a name mismatch or a parse failure, forcing a client to
switch on
type(detail). The Web UI is that client, so this would have becomeits problem.
This covers the 400s, which are the ones carrying per-field findings. The service
error mappings (404 for a missing target, 409 for a conflict) keep FastAPI's
conventional bare-string
detail, since the status code already tells a clientwhat happened and there are no findings to attach.
Three write routes and one authoring read. Scope guards mirror the existing
conventions rather than inventing one; see below.
The #575 P3 fix, folded in because point 5 makes it load-bearing: once
POSTand
PUTrun the validator before persisting, a crash there 500s a write pathrather than a pure read.
On the name-identity rule (point 2)
A profile has two identities: the storage key (its filename stem) and the
frontmatter
name.parse_agent_profile_texttreats the stem only as a fallbackwhen frontmatter omits
name:So
name: fooinbar.mdloads asfoowhile being addressed asbar, andnothing reconciles them. Both write routes now require the two to agree, with a
400 on mismatch.
POSTtakesnameexplicitly in the body rather than parsing it out ofcontent, so the 409 target is unambiguous even when the document is malformed.PUTtreats the path parameter as authoritative.Rename is deliberately not implemented. Your point 2 offers "require them to
match for create/update, or define an explicit rename operation"; this takes
the first branch. Rename is delete-plus-create with its own failure semantics
(partial failure, mid-rename collision, whether references follow) and deserves
its own design rather than riding along here. #510's title covers search, create,
edit, delete and validate, not rename.
On the unresolved authoring read (point 1)
Agreed, and the problem is worse than losing placeholders.
load_agent_profilecallsresolve_env_vars(raw_text)on the raw text beforeparsing, so substitution reaches the Markdown body as well as the frontmatter,
and the substitution source is the managed CAO
.envfile. An edit round-tripthrough
PUTwould therefore write resolved secret values into a plaintextprofile in the local store.
safe_substituteleaves unset variables intact,which makes the damage selective and silent: only the variables a user actually
configured get baked in.
GET /agents/profiles/{name}/sourcereturns the document verbatim.Note on the implementation: it calls the existing
_read_agent_profile_source. That function is named private but already hasimporters in three modules (
cli/commands/profile.py,install_service.py), soit is a de facto public API with a private name. I deliberately did not rename
it here: it is already HTTP-reachable through
GET /agents/profiles/{name}, so asecond caller adds no new exposure, and renaming would mean four call-site edits
in files this PR otherwise does not touch. Worth doing as its own small change.
Scope guards
All three write routes mirror
POST /agents/profiles/install:DELETEincluded. It wasSCOPE_ADMINalone in the first revision and waschanged during review. Two reasons, and the first is the decisive one.
Scopes here are a flat set, not a hierarchy.
require_any_scopetestsmembership and
get_current_scopesreturns the token's claims verbatim with noexpansion, so
cao:admindoes not implycao:writeandcao:writedoes notimply
cao:read. Admin-only onDELETEtherefore does not mean "admins canalso delete", it means a client holding exactly
cao:writeis 403'd, so theprofile-management credential this PR exists to serve could create and edit a
profile but never remove it. That contradicts the contract published in #510,
which specifies
cao:writeorcao:adminfor all three.Second, the precedent I originally leaned on splits differently than I claimed:
DELETE /sessions/{session_name}SCOPE_ADMINDELETE /terminals/{terminal_id}SCOPE_ADMINDELETE /workflows/{name}SCOPE_ADMINDELETE /flows/{name}SCOPE_ADMINDELETE /memory/{key},DELETE /memorySCOPE_ADMINDELETE /memory/relationships/{id}SCOPE_WRITE, SCOPE_ADMINSix of seven do use admin alone, but every one of those removes running or
generated state. The lone write-or-admin exception is the only content
resource in the list. A profile is an authored document: removing one stops no
in-flight work, destroys nothing that cannot be re-authored, and is already
gated behind
ConfirmModalin the UI. It belongs with the relationship delete,not with the session teardown.
Six enforcement tests assert the guards are real rather than merely declared: a
cao:readtoken is 403'd on create and on delete, whilecao:writeis admittedon all three and
cao:adminon delete.These are real mutations, so there are no
_EXEMPTentries, unlike #575'svalidate route. The
_EXEMPTset intest/api/test_scope_coverage.pyisunchanged.
The read route is gated too
GET /agents/profiles/{name}/sourceshipped with no scope dependency in thefirst revision. That was a real hole and it is now
require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), the identical shapeall ten already-guarded
GETroutes use.Worth stating why the repo-wide picture did not excuse it. 29 of 39
GETroutescarry no gate, so my first instinct was that this matched convention. But the
ten that are gated are the sensitive reads (
/memory/export,/memory/relationships,/outcomes,/workflows/runs/{run_id}/result), andthis repo already settled the exact question during the #505 review: the new read
routes got the gate while their pre-existing ungated siblings were deliberately
left alone, because tightening a shipped route risks breaking an existing
unauthenticated reader. I have followed that split, so the five pre-existing
profile reads are untouched.
test_scope_coverage.pydid not catch this:_MUTATING_METHODSis{POST, PUT, PATCH, DELETE}, so a new ungatedGETis invisible to it. The newguard therefore comes with a structural test asserting the dependency exists on
the route object, mirroring
_NEW_505_READ_ROUTES. A status-code test wouldhave been worthless here, and the #505 test says so in its own docstring: auth is
default-off, and
require_any_scopehands back the full scope set when it isoff, so "the route returns 200" passes whether or not the dependency exists at
all. That is precisely how this shipped ungated.
Route ordering
GET /agents/profiles/{name}/sourceis declared afterGET /agents/profiles/{name}, which is safe because the extra path segmentcannot be captured by a single
{name}parameter. There is a test asserting thetwo return different shapes rather than one serving the other, so a future
refactor that collapses them fails loudly.
The #575 P3 fix
Three malformed-but-parseable YAML shapes raised
TypeError, which is not caughtby the handler's
except ValueErrorand therefore surfaced as HTTP 500 from aroute whose entire job is reporting what is wrong with a document:
The two advisory checks test set membership, which hashes the value; the schema
error sort used raw path components, which cannot be ordered across types. Fixes:
stringify the sort key, and type-guard both advisory checks per value so the
schema owns the type error. All three now return 200 with
valid: falseand theschema error attached, which is the correct answer since the schema already
rejects them.
Behaviour changes, disclosed
1.
GET /agents/profiles/{name}'s docstring now points at the source route.No behaviour change, but the resolved-versus-source distinction was previously
undocumented and is easy to get wrong.
2. Both write routes bound
contentat 256 KB via Pydanticmax_length,matching #575's validate route. Generous for a profile, and it avoids an
unbounded parse. Happy to drop the cap.
3. Malformed values that previously crashed now produce different text. They
were already rejected, as unknown keys or unhashable values; now the message
names the actual problem with a path a form can render against.
Deliberate choices worth flagging
Three things a reviewer is likely to question. Each is a decision, not an
oversight, so here is the reasoning up front.
PUTan invalid document to a missing profile returns 400, not 404.Validation precedes persistence, so the malformed body wins over the absent
target:
Defensible either way. I kept validation first because reordering means an
existence pre-check outside the write lock purely for error ordering, and that
reintroduces a TOCTOU-shaped code path a future reader could mistake for the real
guard. The authoritative existence check has to stay inside the lock.
InvalidProfileNameErroronPOSTis probably unreachable. The schema'snamepattern rejects unsafe names beforewrite_profilesees them, and arequest-name / frontmatter-name divergence trips the mismatch check first. Kept as
defence in depth: the schema pattern and
profile_store._PROFILE_NAME_REareindependent guards that could drift apart. Happy to drop the handler if you would
rather not carry an unreachable branch.
The read and write paths validate names with different strictness.
agent_profiles._validate_agent_namerejects only/,\and.., whileprofile_store._PROFILE_NAME_REenforces[A-Za-z0-9_-]{1,64}. Sobad@namereturns 400 on
DELETEand 404 on/source. This asymmetry is pre-existingbetween those two modules and already applies to
GET /agents/profiles/{name};the new source route only makes it visible on one more route. Tightening it would
change the existing read route's behaviour, so it does not belong here. Traversal
is blocked on both paths, so this is a consistency wart rather than a security
gap.
Known gaps, disclosed
Point 4 is still only half closed. #575 fixed field presence by adding
containerandprovider_init_timeout. Field shape is unfinished:toolsSettings,codexConfigandhooksremain bare{"type": "object"}withno properties, so a form generated from
/agents/profiles/schemacannot renderthem as inputs. The UI PR plans validated JSON editors for the object-valued
fields for exactly this reason.
The Markdown body has no explicit round-trip contract. The body is the system
prompt. These routes persist it verbatim and the source route returns it
verbatim, which is the behaviour an editor needs, but the schema still describes
frontmatter only.
No rename, as described above.
Explicitly out of scope
_read_agent_profile_source. Its own change.since it touches the schema rather than the write path.
Testing
Full suite: 6,389 passed, 39 skipped, 111 deselected, 1 xfailed.
My local environment also has 62 failures, confined to
test/api/test_agui_*,test/services/agui/,test/telemetry/test_otel_init.py,test/test_no_ffi_guard.py, and an intermittenttest/services/test_fifo_reader.py.Same count and per-file distribution measured on
mainwith this branch's changesabsent, and none of those modules import profile code. Flagging rather than
omitting, since I can't confirm how they behave in CI.
Counts from
pytest --collect-onlyagainst both this branch and a cleanorigin/mainworktree, not inferred:test/api/test_api_profile_surface.pytest/utils/test_atomic_file.pytest/api/test_scope_coverage.pytest/services/test_profile_store.pytest/services/test_profile_validator.py58 net new tests. An earlier revision of this description said 53, which was
wrong twice over: its own table summed to 48, and the total had been carried
across an edit rather than recomputed. The figures above are re-measured on both
trees.
The ones worth calling out:
PUTandDELETEagainstcode_supervisor, a real shipped built-in, assert404 and that no local file was created. This is the point-3 regression
guard, at both the service and HTTP layers.
DELETEoverlapped with an in-flightPUT, pausing inside the publish so thewindow is deterministic rather than scheduler-dependent. The deleter must still
be blocked on the lock while the replace holds it, which is the assertion that
fails on the unlocked implementation.
must_existholds under contention: bothconcurrent updaters of an absent target are refused and neither creates it.
Named for what it does now; it previously claimed deleter coverage it did not
have.
locked_atomic_deleteis asserted to contend on the same lock as the writers,by holding that lock externally and requiring the delete to time out. A delete
that keyed a different lock, or took none, would return immediately.
route object, plus enforcement tests that a scopeless token is 403'd and a
cao:readtoken is admitted.PUTleaves the existing file byte-identical, so validationgenuinely precedes persistence rather than running alongside it.
block/allow contract the UI depends on.
${MY_TOKEN}unresolved, the point-1 guard.detailshape, so the unified error contract cannot regress silently.
black --check src/ test/andisort --check-onlyclean across 536 files.mypyon both changed service files: no issues.Exercised against a running server
Every endpoint test monkeypatches the store, so the routes were also driven over
real HTTP against
cao-serverwithCAO_HOME_DIRpointed at a throwawaydirectory. Six checks, all as expected.
Happy path:
The
sourceresponse is the point-1 evidence. The stored document containedToken: ${MY_TOKEN}and came back with the placeholder intact:{"name": "probe", "content": "---\nname: probe\ndescription: Local check.\n---\n\nToken: ${MY_TOKEN}\n"}GET /agents/profiles/probewould have returned that substituted from the managedenvironment file, and writing it back would have persisted the resolved value.
Built-in protection, the point-3 guard:
code_supervisoris a profile that ships with the package. A 200 there would meana local file had been created that shadows it on load.
Unified rejection shape, two different causes on the same route:
Same keys either way, and the field-level failure carries
path: "engine"so aform can render the error against the right input.
What comes next
The UI: a Profiles panel and nav tab consuming this surface, with inline
validation through
POST /agents/profiles/validate, a from-scratch form driven byGET /agents/profiles/schema, and clone-to-customise for built-ins.Ref #510. Builds on #523 (read surface), #543 (
profile_store,locked_atomic_write) and #575 (validator service, validate and schema routes).