Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions aikit
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,22 @@ def save_config(config):
CONFIG_FILE.chmod(0o600)


def _validate_config_patch(patch):
"""Validate a POST /api/config body. Returns an error string or None."""
if not isinstance(patch, dict):
return "Request body must be a JSON object"
unknown = set(patch) - set(DEFAULT_CONFIG)
if unknown:
return f"Unknown config keys: {', '.join(sorted(unknown))}"
if "version" in patch and not isinstance(patch["version"], int):
return "version must be an integer"
if "agents" in patch and not isinstance(patch["agents"], dict):
return "agents must be an object"
if "settings" in patch and not isinstance(patch["settings"], dict):
return "settings must be an object"
return None


def _needs_setup():
return not SETUP_MARKER.exists()

Expand Down Expand Up @@ -5061,12 +5077,13 @@ def create_flask_app():
@app.route("/api/config", methods=["GET", "POST"])
def api_config():
if request.method == "POST":
try:
new_config = request.get_json()
save_config(new_config)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"error": str(e)}), 400
patch = request.get_json(silent=True)
err = _validate_config_patch(patch)
if err:
return jsonify({"error": err}), 400
config = load_config()
save_config(_deep_merge(config, patch))
return jsonify({"ok": True})

config = load_config()
def mask(obj):
Expand Down
8 changes: 7 additions & 1 deletion docs/aikit.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Dashboard features:
| `/api/install/<key>` | POST | Install an agent |
| `/api/update/<key>` | POST | Update an agent |
| `/api/doctor` | GET | Environment diagnostics |
| `/api/config` | GET/POST | Read/update configuration |
| `/api/config` | GET/POST | Read config (GET) or deep-merge a partial update (POST) |

---

Expand All @@ -143,6 +143,12 @@ aikit config get settings.web_port # Get a specific value
aikit config set settings.web_port 9000 # Set a value
```

`POST /api/config` accepts a partial JSON object and **deep-merges** it into the
loaded config (same semantics as `aikit config set` for nested keys). Only known
top-level keys (`version`, `agents`, `settings`) are accepted; unknown keys or
wrong types return HTTP 400. An empty nested object (e.g. `"agents": {}`) does
not wipe sibling keys under that section.

---

## Gateway
Expand Down
81 changes: 81 additions & 0 deletions tests/test_aikit_api_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Tests for aikit POST /api/config deep-merge and validation."""

from __future__ import annotations

import json

import pytest


@pytest.fixture
def aikit_app(tool_loader, tmp_path, monkeypatch):
m = tool_loader("aikit")
config_dir = tmp_path / ".aikit"
config_file = config_dir / "config.json"
monkeypatch.setattr(m, "CONFIG_DIR", config_dir)
monkeypatch.setattr(m, "CONFIG_FILE", config_file)
monkeypatch.delenv("AIKIT_SETTINGS__WEB_PORT", raising=False)
app = m.create_flask_app()
app.config["TESTING"] = True
return m, app.test_client(), config_file


def _write_config(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n")


def test_api_config_post_deep_merges_settings(aikit_app):
m, client, config_file = aikit_app
_write_config(config_file, {
"version": 1,
"agents": {"cursor": {"installed": True, "version": "1.0"}},
"settings": {"web_port": 8765, "web_host": "localhost"},
})

resp = client.post(
"/api/config",
json={"settings": {"web_port": 8801}, "version": 1, "agents": {}},
)
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}

saved = json.loads(config_file.read_text())
assert saved["settings"]["web_port"] == 8801
assert saved["settings"]["web_host"] == "localhost"
assert saved["agents"] == {"cursor": {"installed": True, "version": "1.0"}}


def test_api_config_post_rejects_unknown_keys(aikit_app):
_, client, config_file = aikit_app
_write_config(config_file, {"version": 1, "agents": {}, "settings": {}})

resp = client.post("/api/config", json={"bogus": 1})
assert resp.status_code == 400
assert "Unknown config keys" in resp.get_json()["error"]


def test_api_config_post_rejects_non_object(aikit_app):
_, client, _ = aikit_app

resp = client.post(
"/api/config",
data="[]",
content_type="application/json",
)
assert resp.status_code == 400
assert resp.get_json()["error"] == "Request body must be a JSON object"


def test_api_config_post_rejects_invalid_types(aikit_app):
_, client, config_file = aikit_app
_write_config(config_file, {"version": 1, "agents": {}, "settings": {}})

resp = client.post("/api/config", json={"settings": "nope"})
assert resp.status_code == 400
assert resp.get_json()["error"] == "settings must be an object"


def test_validate_config_patch_accepts_partial(aikit_app):
m, _, _ = aikit_app
assert m._validate_config_patch({"settings": {"web_port": 9000}}) is None
Loading