feat(api): REST Admin API — runtime status, config read, model listing, and cron job management#770
Conversation
Add GET /api/channels, GET /api/channels/:name, GET /api/skills, POST /api/skills/install, and DELETE /api/skills/:name to the REST admin API (src/api/api.zig). Channels endpoints enumerate configured accounts from ChannelsConfig (all 22 channel types via comptime inline iteration) and cross-reference the global health registry for per-type status. No credentials are exposed in responses. Skills endpoints scan the skillforge output_dir for installed skills, delegate install to skillforge.scout+evaluateCandidate+integrate, and remove skill directories with sanitizePathComponent path-traversal protection. Live network/filesystem calls are guarded by builtin.is_test. Adds 13 new tests (6 channels, 7 skills); all pass with 0 leaks. Depends on PR nullclaw#770 (feat/rest-admin-api).
manelsen
left a comment
There was a problem hiding this comment.
First off — this is genuinely impressive work. The architecture is clean, the src/api/ separation is well thought out, the JSON envelope is consistent throughout, auth gating is correctly placed before any dispatch, GET /api/models deliberately returns has_key instead of the key value, the mutex handling mirrors the existing /cron pattern exactly, and the test suite is thorough. This is the kind of PR that makes a codebase better just by existing.
I have one blocker that needs a small fix before I can recommend merge, and a few minor notes below.
Blocker — GET /api/config can expose API keys
handleConfig calls config_mutator.getPathValueJson(ctx.allocator, path_param) with the path coming directly from the query string, with no filtering. isAllowedPath in config_mutator is only enforced on mutations, not reads. So an authenticated client who knows the config schema can do:
GET /api/config?path=models.providers.0.api_key
→ {"success":true,"data":{"path":"...","value":"sk-live-..."}}
Same for pairing tokens, webhook secrets, or any other credential stored in config.json.
The PR description says the intent is to read "non-sensitive values like default_provider" — the implementation just doesn't enforce that yet.
Suggested fix — one of these, whichever feels right to you:
Option A — explicit deny-list in handleConfig (fast, surgical):
const sensitive_suffixes = [_][]const u8{ "api_key", "token", "secret", "password", "pairing" };
for (sensitive_suffixes) |suffix| {
if (std.mem.endsWith(u8, path_param, suffix)) {
try ctx.sendError("403 Forbidden", "PATH_FORBIDDEN", "that config path is not readable via the API");
return;
}
}Option B — allowlist (safer long-term): reuse or extend isAllowedPath from config_mutator to cover readable paths too.
Minor notes (non-blocking)
sendError message safety — the message parameter is interpolated directly into a JSON string via {s}. Today every call site passes a string literal or @errorName(err) (which is always ASCII-safe), so there's no practical risk. But a doc comment noting "callers must ensure message contains no JSON-special characters" would prevent a future regression.
GET /api/config path injection — you already call jsonEscapeString on path_param when embedding it in the response (line 339), which is correct. Just noting it's good.
Test coverage — I looked at all 78 tests. Coverage of auth, routing, envelope shape, and error paths is solid. One gap worth considering: a test asserting that GET /api/config?path=models.providers.0.api_key returns 403 after the fix lands, to lock in the regression protection.
Really looking forward to seeing this merged once the config read path is tightened up — the Admin API layer is going to be very useful. Happy to re-review quickly once updated. @DonPrus tagging you for visibility.
| return; | ||
| }; | ||
|
|
||
| const value_json = config_mutator.getPathValueJson(ctx.allocator, path_param) catch |err| { |
There was a problem hiding this comment.
No path filtering here before the read. getPathValueJson has no allowlist — it will happily return models.providers.0.api_key or any other credential stored in config.json. Needs a deny-list for sensitive suffixes (api_key, token, secret, pairing) or a read-specific allowlist before this is safe to ship.
|
Thanks for the thorough review, @manelsen — the config read path analysis is spot on. Here is our plan before we push a revision: Blocker —
|
Introduces the /api/v1/* REST Admin API surface for trusted clients (e.g. NullClaw iOS app) as an opt-in gateway feature. Changes: - src/api/context.zig: ApiContext struct (per-request context, mirrors WebhookHandlerContext pattern; sendSuccess/sendError envelope helpers) - src/api/api.zig: comptime Endpoint registry, dispatch() entry point, handleHealth handler (GET /api/v1/health — structured component health, pid, uptime); 10 new tests - src/config_types.zig: GatewayConfig.admin_api bool field (default false) - src/config_parse.zig: parse gateway.admin_api from config.json - src/gateway.zig: wire api.dispatch() into the main request loop; import api/api.zig; /api/v1/* branch with same cron-style auth guard - config.example.json: add admin_api: false under gateway - docs/en/gateway-api.md: document /api/v1/ section, response envelope, enabling instructions, and Phase 0 endpoint reference
Adds three new GET endpoints to the REST Admin API: - GET /api/v1/status: version string, pid, uptime (lightweight identity check) - GET /api/v1/config?path=<dotted.path>: read a single config value from disk via config_mutator.getPathValueJson; returns raw JSON value in envelope - GET /api/v1/models: list configured providers (name + has_key flag only; key values never returned) plus default_provider and default_model Also adds helpers extractQueryParam and jsonEscapeString (both tested), and 8 new tests covering all Phase 1 paths.
…e routing to top
- Drop version prefix: all endpoints now live under /api/ (YAGNI — no v2 caller)
- Remove GET /api/v1/health; merge component-health logic into GET /api/status so
the single status endpoint returns version, pid, uptime, overall status, and
per-component detail
- Move /api/ routing block to the TOP of the gateway chain so /api/cron/* is
handled by the structured API instead of the legacy /cron handlers
- Simplify prefix check: startsWith("/api/") replaces the previous
startsWith("/api/v1/") or eql("/api/v1") condition
- Update all 26 api.zig tests, context.zig docs/tests, and gateway-api.md
Test result: 6312/6318 passing, 0 leaks (matches pre-change baseline)
…fety note, fix docs heading - Add isAllowedReadPath() to config_mutator with a narrow allowlist of 11 safe non-sensitive paths; blocks credential-bearing paths (api_key, bot_token, pairing tokens) that isAllowedPath (mutation) would expose - Guard handleConfig in api.zig with isAllowedReadPath before calling getPathValueJson; returns 403 PATH_FORBIDDEN on blocked paths - Expand sendError doc comment: callers must not pass user-supplied strings without escaping JSON-special characters - Fix leading-space typo on '## Endpoints' heading in gateway-api.md Fixes blocker raised in PR review. Adds 4 regression tests.
- Replace buf.writer(allocator) with std.Io.Writer.Allocating pattern - Update health.snapshot() call to pass allocator and defer deinit - Iterate HealthSnapshot.components as []SnapshotComponent slice - Refactor appendCronJobJson/appendJsonString to take anytype writer, eliminating nested writer-on-same-buf pattern - Fix snap.pid ?u32 formatting with 'orelse 0'
7832938 to
bfb234d
Compare
|
All review comments have been addressed — summary below. Blocker —
Docs heading typo Zig 0.16 compatibility |
Summary
Adds an opt-in REST Admin API under
/api/that gives lightweight clients (menubar apps, iOS/iPadOS, CLI dashboards) a uniform, structured interface for gateway inspection and control.gateway.admin_api: false). Returns403 ADMIN_API_DISABLEDuntil explicitly enabled./cron. ReusesextractBearerToken+isWebhookAuthorizedfromgateway.zig. No new auth surface.{"success":true,"data":{...},"error":null}/{"success":false,"data":null,"error":{"code":"...","message":"..."}}.src/api/abstraction layer. Handlers live inapi.zig;gateway.zigcallsapi.dispatch(). No circular imports.Endpoints
/healthis unchanged (plain{"status":"ok"}for external health checkers)./api/cron/*wins over legacy/cronhandlers — the/api/routing block is first in the chain.Key design decisions
/api/v1/was rejected (YAGNI — no v2 caller); flat/api/prefix chosen./api/statussubsumes/api/health. Single endpoint returns version + pid + uptime + overall status + component map; no separate health endpoint./api/routing block at the top of thegateway.zigif/else chain so structured cron endpoints take precedence over the legacy/cronhandlers.gateway.zigcallslockSharedSchedulerForRequest()beforeapi.dispatch()and unlocks after return; handlers never touch the mutex.Files changed
src/api/api.zigsrc/api/context.zigsendSuccess/sendErrorhelperssrc/gateway.zig/api/prefix check at top of routing chain; pass locked scheduler toapi.dispatch()src/config_types.zigGatewayConfig.admin_api: bool = falsesrc/config_parse.ziggateway.admin_apifrom JSONconfig.example.jsonadmin_apifielddocs/en/gateway-api.mdGET /api/statusresponse shapeTest results
Pre-existing failure:
agent.cli.test.writeRateLimitHint mentions reliability knobs and logs— crashes onmainas well, unrelated to this PR.Validation