Skip to content

feat(api): REST Admin API — runtime status, config read, model listing, and cron job management#770

Open
vernonstinebaker wants to merge 5 commits intonullclaw:mainfrom
vernonstinebaker:feat/rest-admin-api
Open

feat(api): REST Admin API — runtime status, config read, model listing, and cron job management#770
vernonstinebaker wants to merge 5 commits intonullclaw:mainfrom
vernonstinebaker:feat/rest-admin-api

Conversation

@vernonstinebaker
Copy link
Copy Markdown
Contributor

@vernonstinebaker vernonstinebaker commented Apr 5, 2026

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.

  • Zero new dependencies. Single static Zig binary; estimated binary size increase < 30 KB.
  • Opt-in. Disabled by default (gateway.admin_api: false). Returns 403 ADMIN_API_DISABLED until explicitly enabled.
  • Auth mirrors /cron. Reuses extractBearerToken + isWebhookAuthorized from gateway.zig. No new auth surface.
  • Standard JSON envelope everywhere. {"success":true,"data":{...},"error":null} / {"success":false,"data":null,"error":{"code":"...","message":"..."}}.
  • src/api/ abstraction layer. Handlers live in api.zig; gateway.zig calls api.dispatch(). No circular imports.

Endpoints

GET    /api/status               version, pid, uptime, overall_status, components
GET    /api/config?path=<dotted> read a config value (on-disk, dotted path)
GET    /api/models               provider list — names + has_key, never key values
GET    /api/cron                 list all scheduled jobs
POST   /api/cron                 create recurring cron job
POST   /api/cron/once            create one-shot delayed job
POST   /api/cron/:id/run         trigger immediately
POST   /api/cron/:id/pause       pause job
POST   /api/cron/:id/resume      resume job
PATCH  /api/cron/:id             update job fields
DELETE /api/cron/:id             remove job

/health is unchanged (plain {"status":"ok"} for external health checkers).
/api/cron/* wins over legacy /cron handlers — the /api/ routing block is first in the chain.

Key design decisions

  • No version prefix. /api/v1/ was rejected (YAGNI — no v2 caller); flat /api/ prefix chosen.
  • /api/status subsumes /api/health. Single endpoint returns version + pid + uptime + overall status + component map; no separate health endpoint.
  • /api/ routing block at the top of the gateway.zig if/else chain so structured cron endpoints take precedence over the legacy /cron handlers.
  • Scheduler passed pre-locked. gateway.zig calls lockSharedSchedulerForRequest() before api.dispatch() and unlocks after return; handlers never touch the mutex.

Files changed

File Change
src/api/api.zig New — registry, dispatcher, 11 handlers, path-param matching, 26 tests
src/api/context.zig New — per-request context struct, sendSuccess/sendError helpers
src/gateway.zig Wire /api/ prefix check at top of routing chain; pass locked scheduler to api.dispatch()
src/config_types.zig Add GatewayConfig.admin_api: bool = false
src/config_parse.zig Parse gateway.admin_api from JSON
config.example.json Document admin_api field
docs/en/gateway-api.md Full endpoint table, auth, envelope, and GET /api/status response shape

Test results

6312/6318 passing, 0 leaks

Pre-existing failure: agent.cli.test.writeRateLimitHint mentions reliability knobs and logs — crashes on main as well, unrelated to this PR.

Validation

zig build test --summary all   # 6312/6318 passing, 0 leaks
zig fmt --check src/            # clean

@vernonstinebaker vernonstinebaker marked this pull request as draft April 5, 2026 15:31
@vernonstinebaker vernonstinebaker changed the title feat(api): REST Admin API — Phase 0 + Phase 1 (/api/v1/health, /status, /config, /models) feat(api): REST Admin API — Phase 0+1+2 (/api/status, /config, /models, /cron CRUD) Apr 5, 2026
@vernonstinebaker vernonstinebaker marked this pull request as ready for review April 5, 2026 15:36
vernonstinebaker added a commit to vernonstinebaker/nullclaw that referenced this pull request Apr 5, 2026
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).
@vernonstinebaker vernonstinebaker changed the title feat(api): REST Admin API — Phase 0+1+2 (/api/status, /config, /models, /cron CRUD) feat(api): REST Admin API — runtime status, config read, model listing, and cron job management Apr 6, 2026
Copy link
Copy Markdown
Contributor

@manelsen manelsen left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/api/api.zig
return;
};

const value_json = config_mutator.getPathValueJson(ctx.allocator, path_param) catch |err| {
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.

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.

@vernonstinebaker
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @manelsen — the config read path analysis is spot on. Here is our plan before we push a revision:

Blocker — GET /api/config path filtering

We will go with Option B (allowlist), matching the approach used for mutations.

Step 1 — Add pub fn isAllowedReadPath to src/config_mutator.zig

A new, separate allowlist for reads — narrower than the mutation list because read access has a different threat model. The mutation allowlist allows channels., security., and models.providers. prefixes that can all contain API keys and tokens. The read allowlist will cover only clearly non-sensitive dotted paths (e.g. default_temperature, reasoning_effort, gateway.port, gateway.host, tunnel.provider, agents.defaults.model.primary, and the memory. and scheduler. prefixes). The channels.*, security.*, and models.providers.* prefixes will be absent from the read list.

Step 2 — Guard handleConfig in src/api/api.zig

After extracting path_param, add before the getPathValueJson call:

if (!config_mutator.isAllowedReadPath(path_param)) {
    try ctx.sendError("403 Forbidden", "PATH_FORBIDDEN", "that config path is not readable via the API");
    return;
}

Step 3 — Add regression tests

Two new test cases in src/api/api.zig:

  • GET /api/config?path=models.providers.0.api_key → 403 PATH_FORBIDDEN
  • GET /api/config?path=channels.telegram.accounts.default.bot_token → 403 PATH_FORBIDDEN

Plus a positive case (default_temperature) to confirm the allowlist is wired correctly.

Also add tests for isAllowedReadPath in config_mutator.zig covering at least one forbidden prefix and one allowed path.


Minor notes we will also address

sendError doc comment — we will add a // NOTE: message must contain no JSON-special characters (callers pass string literals or @errorName output only) comment above the function in context.zig.

Docs markdown typo — the diff introduces a leading space on ## Endpoints (becomes ## Endpoints). We will restore the correct heading while keeping the line change.


Once these are in we will run zig build test --summary all to confirm 0 failures and 0 leaks before pushing the revision.

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'
@vernonstinebaker
Copy link
Copy Markdown
Contributor Author

All review comments have been addressed — summary below.

Blocker — GET /api/config key exposure (Option B, allowlist)
Added pub fn isAllowedReadPath(path: []const u8) bool to config_mutator.zig with an explicit allowlist of 11 safe paths (default_provider, default_model, gateway.port, gateway.host, gateway.admin_api, gateway.rate_limit, gateway.timeout_secs, gateway.max_body_bytes, scheduler.enabled, scheduler.max_jobs) plus prefix matches for diagnostics. and scheduler.. handleConfig returns 403 PATH_FORBIDDEN for anything not on the list. Security-sensitive paths (models.providers.*.api_key, channels.*.bot_token, security.*, etc.) are absent by design. Tests added for both the forbidden-path 403 and a passing allowed-path case.

sendError message safety
Added a doc comment to sendError in context.zig noting that callers must ensure message contains no JSON-special characters.

Docs heading typo
Fixed the leading-space on the ## Endpoints heading in gateway-api.md.

Zig 0.16 compatibility
The branch has been rebased onto main (362e93f) and api.zig updated to the Zig 0.16 API: std.Io.Writer.Allocating writer pattern, health.snapshot(allocator) with defer deinit, and []SnapshotComponent slice iteration. Builds and tests clean (1 pre-existing failure in tools.web_search also present on main).

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.

2 participants