diff --git a/aikit b/aikit index 8ca4a2b..30adea1 100755 --- a/aikit +++ b/aikit @@ -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() @@ -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): diff --git a/docs/aikit.md b/docs/aikit.md index b06cc77..08c09e2 100644 --- a/docs/aikit.md +++ b/docs/aikit.md @@ -124,7 +124,7 @@ Dashboard features: | `/api/install/` | POST | Install an agent | | `/api/update/` | 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) | --- @@ -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 diff --git a/tests/test_aikit_api_config.py b/tests/test_aikit_api_config.py new file mode 100644 index 0000000..0e85c73 --- /dev/null +++ b/tests/test_aikit_api_config.py @@ -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