From 7319160ee70132379751f44ce654c1871cf23834 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 09:43:39 +0000 Subject: [PATCH] Derive installer acceptance rules from harness declarations --- deploy/install/README.md | 1 + deploy/install/acceptance.py | 20 +++-- deploy/install/harness_catalog.py | 7 ++ deploy/install/test_acceptance.py | 106 +++++++++++++++++++++++ scripts/generate-harness-catalog.py | 35 +++++++- scripts/generate-harness-catalog.test.py | 29 +++++++ scripts/harness-catalog/main.go | 31 +++++++ scripts/harness-catalog/main_test.go | 27 ++++++ 8 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 deploy/install/harness_catalog.py create mode 100644 deploy/install/test_acceptance.py create mode 100644 scripts/harness-catalog/main.go create mode 100644 scripts/harness-catalog/main_test.go diff --git a/deploy/install/README.md b/deploy/install/README.md index 0861ac60a..dfa66eb30 100644 --- a/deploy/install/README.md +++ b/deploy/install/README.md @@ -30,6 +30,7 @@ This directory holds the Core/Web installer, the `oac` command and the node inst ## Configuration and apply - Every process setting has one home: the installation's private `config.json`, described by `config.schema.json`. Keep the schema, `config_model.py`, the generator and the reference tables that `scripts/config-reference.py` renders into [configuration](../../docs/configuration.md) and [installation options](../../docs/getting-started/install-options.md) in step. +- `make generate-harness-catalog` projects the [built-in Harness catalog](../../internal/harnessconfig/builtin/catalog.json) and adapter-owned Go provider declarations into `harness_catalog.py`. `make check-harness-catalog` checks its freshness, schema enum membership and process-default membership. `acceptance.py` uses this projection to validate its explicitly selected campaign combinations and token-limit requirements; adding catalog support does not expand the live acceptance campaign. These acceptance modules run from the checkout; installed configuration and `oac.pyz` use the checked schema. - Installation flags only seed `config.json`. A rerun of the installer over a complete installation accepts only `--install-dir` and repairs. - `oac apply` validates `config.json`, derives `generated/` and converges on what actually runs. Each service carries the digest of its inputs (Compose label `io.oac.inputs`, native `OAC_INPUTS`), and exactly the services whose running inputs differ are recreated or restarted. Decide restarts from what runs, never from recorded bookkeeping, so the next apply finishes an interrupted one. Apply contacts running services at the last applied address before changing listeners. - Health checks, setup, apply and generated service files derive addresses from the same `config.json`. `host` selects the gateway listener for managed ingress and the Core and Web listeners for external ingress. A non-loopback external bind requires an HTTPS public origin. diff --git a/deploy/install/acceptance.py b/deploy/install/acceptance.py index 303888afb..941fa5deb 100644 --- a/deploy/install/acceptance.py +++ b/deploy/install/acceptance.py @@ -34,7 +34,11 @@ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener import uuid -HARNESSES = ("codex", "claude_sdk", "mcode") +from harness_catalog import PROVIDERS + +# This campaign deliberately qualifies only these combinations. Catalog additions +# do not expand live model calls; supported combinations are declared in PROVIDERS. +CAMPAIGN = {"codex": "responses", "claude_sdk": "anthropic", "mcode": "anthropic"} TERMINAL = {"completed", "cancelled", "failed"} MAX_RESPONSE = 8 * 1024 * 1024 @@ -87,10 +91,12 @@ def validate_origin(raw, model=False): def settings(args): + require(CAMPAIGN and all(harness in PROVIDERS and protocol in PROVIDERS[harness] + for harness, protocol in CAMPAIGN.items()), "unsupported_acceptance_campaign") token = private_file(args.caller_key_file).strip() require(token and not any(c in token for c in "\r\n\0"), "invalid_caller_key") models = json.loads(private_file(args.model_config_file)) - require(isinstance(models, dict) and set(models) == set(HARNESSES), "require_all_three_harness_configs") + require(isinstance(models, dict) and set(models) == set(CAMPAIGN), "require_all_three_harness_configs") secrets = [token] for harness, entry in models.items(): require(isinstance(entry, dict) and set(entry) == {"model", "model_provider"}, "invalid_model_config_fields") @@ -101,17 +107,17 @@ def settings(args): require(set(provider) <= allowed and {"protocol", "base_url", "api_key"} <= set(provider), "invalid_model_provider_fields") validate_origin(provider["base_url"], model=True) - protocol = "responses" if harness == "codex" else "anthropic" - require(provider["protocol"] == protocol, "provider_protocol_does_not_match_harness") + protocol = CAMPAIGN[harness] + require(provider["protocol"] == protocol, "provider_protocol_does_not_match_campaign") key = provider["api_key"] require(isinstance(key, str) and key and len(key) <= 16384 and not any(c in key for c in "\r\n\0"), "invalid_model_provider_key") for name in ("context_window", "max_output_tokens"): require(type(provider.get(name, 0)) is int and provider.get(name, 0) >= 0, "invalid_model_limits") - if harness == "mcode": + if PROVIDERS[harness][protocol]["requires_token_limits"]: require(provider.get("context_window", 0) > 0 and provider.get("max_output_tokens", 0) > 0, - "mcode_requires_positive_model_limits") + "harness_requires_positive_model_limits") if provider.get("context_window") and provider.get("max_output_tokens"): require(provider["max_output_tokens"] <= provider["context_window"], "invalid_model_limits") secrets.append(key) @@ -404,7 +410,7 @@ def main(): args.report_dir.mkdir(mode=0o700, parents=True, exist_ok=True) require(args.report_dir.stat().st_mode & 0o077 == 0, "report_directory_must_be_private") failed = False - for harness in HARNESSES: + for harness in CAMPAIGN: location = args.report_dir / (harness + ".json") record = json.loads(private_file(location)) if location.exists() else {"harness": harness, "stages": {}} require(record.get("harness") == harness, "report_harness_mismatch") diff --git a/deploy/install/harness_catalog.py b/deploy/install/harness_catalog.py new file mode 100644 index 000000000..2c565796c --- /dev/null +++ b/deploy/install/harness_catalog.py @@ -0,0 +1,7 @@ +# Code generated by scripts/generate-harness-catalog.py; DO NOT EDIT. +# Provider support comes from the adapter-owned Go configuration declarations. +PROVIDERS = {'claude_sdk': {'anthropic': {'requires_token_limits': False}}, + 'codex': {'responses': {'requires_token_limits': False}}, + 'mcode': {'anthropic': {'requires_token_limits': True}, + 'chat_completions': {'requires_token_limits': True}, + 'responses': {'requires_token_limits': True}}} diff --git a/deploy/install/test_acceptance.py b/deploy/install/test_acceptance.py new file mode 100644 index 000000000..fcb8f5e59 --- /dev/null +++ b/deploy/install/test_acceptance.py @@ -0,0 +1,106 @@ +"""Validate acceptance inputs without contacting Core or model providers.""" +import copy +import json +from types import SimpleNamespace +import unittest +from unittest import mock + +import acceptance + + +class AcceptanceSettingsTests(unittest.TestCase): + def setUp(self): + self.models = { + harness: {"model": "fixture", "model_provider": { + "protocol": protocol, "base_url": "https://model.example/v1", "api_key": "private-key", + "context_window": 64000, "max_output_tokens": 4096, + }} for harness, protocol in acceptance.CAMPAIGN.items() + } + + def settings(self, models=None): + models = self.models if models is None else models + with mock.patch.object(acceptance, "private_file", side_effect=["caller-key", json.dumps(models)]): + return acceptance.settings(SimpleNamespace(caller_key_file=None, model_config_file=None)) + + def test_campaign_is_explicit_and_valid(self): + self.assertEqual(acceptance.CAMPAIGN, { + "codex": "responses", "claude_sdk": "anthropic", "mcode": "anthropic"}) + token, models, secrets = self.settings() + self.assertEqual(token, "caller-key") + self.assertEqual(models, self.models) + self.assertEqual(secrets, ["caller-key"] + ["private-key"] * 3) + + def test_catalog_support_does_not_expand_campaign(self): + for protocol in ("responses", "chat_completions"): + self.assertIn(protocol, acceptance.PROVIDERS["mcode"]) + self.models["mcode"]["model_provider"]["protocol"] = protocol + with self.subTest(protocol=protocol), self.assertRaisesRegex( + acceptance.Failure, "provider_protocol_does_not_match_campaign"): + self.settings() + expanded = {**acceptance.PROVIDERS, "additional": {"responses": {"requires_token_limits": False}}} + with mock.patch.object(acceptance, "PROVIDERS", expanded): + self.models["mcode"]["model_provider"]["protocol"] = "anthropic" + self.settings() + self.models["additional"] = copy.deepcopy(self.models["codex"]) + with self.assertRaisesRegex(acceptance.Failure, "require_all_three_harness_configs"): + self.settings() + + def test_unsupported_campaign_is_rejected_before_reading_secrets(self): + for campaign in ({}, {"unknown": "responses"}, {"codex": "anthropic"}): + with self.subTest(campaign=campaign), mock.patch.object(acceptance, "CAMPAIGN", campaign), \ + mock.patch.object(acceptance, "private_file") as private_file: + with self.assertRaisesRegex(acceptance.Failure, "unsupported_acceptance_campaign"): + acceptance.settings(SimpleNamespace()) + private_file.assert_not_called() + + def test_token_limit_requirements_follow_declarations(self): + for harness in acceptance.CAMPAIGN: + models = copy.deepcopy(self.models) + del models[harness]["model_provider"]["context_window"] + del models[harness]["model_provider"]["max_output_tokens"] + if harness == "mcode": + with self.assertRaisesRegex(acceptance.Failure, "harness_requires_positive_model_limits"): + self.settings(models) + else: + self.settings(models) + providers = copy.deepcopy(acceptance.PROVIDERS) + providers["codex"]["responses"]["requires_token_limits"] = True + del self.models["codex"]["model_provider"]["context_window"] + with mock.patch.object(acceptance, "PROVIDERS", providers): + with self.assertRaisesRegex(acceptance.Failure, "harness_requires_positive_model_limits"): + self.settings() + + def test_model_limit_validation(self): + for field in ("context_window", "max_output_tokens"): + for value in (False, -1, "100", 1.5, None): + models = copy.deepcopy(self.models) + models["codex"]["model_provider"][field] = value + with self.subTest(field=field, value=value), self.assertRaisesRegex( + acceptance.Failure, "invalid_model_limits"): + self.settings(models) + for field in ("context_window", "max_output_tokens"): + models = copy.deepcopy(self.models) + models["mcode"]["model_provider"][field] = 0 + with self.assertRaisesRegex(acceptance.Failure, "harness_requires_positive_model_limits"): + self.settings(models) + self.models["codex"]["model_provider"]["max_output_tokens"] = 64001 + with self.assertRaisesRegex(acceptance.Failure, "invalid_model_limits"): + self.settings() + + def test_provider_validation_is_preserved(self): + for patch, label in ( + ({"unknown": "value"}, "invalid_model_provider_fields"), + ({"api_key": "secret\nvalue"}, "invalid_model_provider_key"), + ({"api_key": ""}, "invalid_model_provider_key"), + ({"base_url": "http://model.example"}, "model_origin_requires_https"), + ({"base_url": "https://api.openai.com"}, "official_openai_origin_refused"), + ({"protocol": "anthropic"}, "provider_protocol_does_not_match_campaign"), + ): + models = copy.deepcopy(self.models) + models["codex"]["model_provider"].update(patch) + with self.subTest(label=label), self.assertRaisesRegex(acceptance.Failure, label): + self.settings(models) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/generate-harness-catalog.py b/scripts/generate-harness-catalog.py index eedac3df3..0a9b7f978 100644 --- a/scripts/generate-harness-catalog.py +++ b/scripts/generate-harness-catalog.py @@ -6,6 +6,7 @@ from pathlib import Path import re import subprocess +from pprint import pformat ROOT = Path(__file__).resolve().parent.parent CATALOG = Path("internal/harnessconfig/builtin/catalog.json") @@ -40,6 +41,25 @@ def go(source): check=True, capture_output=True).stdout +def check_installer_schema(entries, schema): + """Process defaults remain authored in the schema; membership is catalog-owned.""" + kinds = [entry["kind"] for entry in entries] + core = schema["properties"]["core"]["properties"] + enabled, default = core["harnesses"], core["default_harness"] + if enabled["items"]["enum"] != kinds or default["enum"] != kinds: + raise ValueError("Installer Harness enums must match internal/harnessconfig/builtin/catalog.json") + if (not enabled["default"] or len(set(enabled["default"])) != len(enabled["default"]) + or not set(enabled["default"]) <= set(kinds) + or default["default"] not in enabled["default"]): + raise ValueError("Installer Harness defaults must select registered, enabled Harnesses") + + +def render_installer(providers): + return ("# Code generated by scripts/generate-harness-catalog.py; DO NOT EDIT.\n" + "# Provider support comes from the adapter-owned Go configuration declarations.\n" + "PROVIDERS = " + pformat(providers, sort_dicts=True, width=100) + "\n") + + def render(entries): packages = sorted({entry["configuration"] for entry in entries}) imports = "\n".join(f'\t"{MODULE}/internal/harnessconfig/{package}"' for package in packages) @@ -114,14 +134,27 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true") args = parser.parse_args() + entries = load_catalog(ROOT / CATALOG) + check_installer_schema(entries, json.loads((ROOT / "deploy/install/config.schema.json").read_text())) stale = [] - for relative, content in render(load_catalog(ROOT / CATALOG)).items(): + + def projection(relative, content): path = ROOT / relative if args.check: if not path.exists() or path.read_text() != content: stale.append(str(relative)) else: path.write_text(content) + + for relative, content in render(entries).items(): + projection(relative, content) + # Refresh the Go registration before loading the adapter declarations. In check + # mode a stale registration must fail before its declarations can be projected. + if stale: + raise SystemExit("Stale Harness catalog projections; run make generate-harness-catalog:\n" + "\n".join(stale)) + providers = json.loads(subprocess.run(["go", "run", "./scripts/harness-catalog"], cwd=ROOT, + text=True, check=True, capture_output=True).stdout) + projection(Path("deploy/install/harness_catalog.py"), render_installer(providers)) if stale: raise SystemExit("Stale Harness catalog projections; run make generate-harness-catalog:\n" + "\n".join(stale)) diff --git a/scripts/generate-harness-catalog.test.py b/scripts/generate-harness-catalog.test.py index 67af0f0d0..88d8ddc06 100644 --- a/scripts/generate-harness-catalog.test.py +++ b/scripts/generate-harness-catalog.test.py @@ -2,6 +2,7 @@ """Verify catalog extension and generated contract freshness without live models.""" import importlib.util +import copy import json from pathlib import Path import tempfile @@ -21,6 +22,34 @@ def module(name, file): class HarnessCatalogTests(unittest.TestCase): + def test_installer_schema_membership_and_defaults(self): + entries = catalog.load_catalog(catalog.ROOT / catalog.CATALOG) + document = json.loads((catalog.ROOT / "deploy/install/config.schema.json").read_text()) + catalog.check_installer_schema(entries, document) + for key, field, value in ( + ("harnesses", "items", {"enum": ["unknown"]}), + ("default_harness", "enum", ["unknown"]), + ("harnesses", "default", ["unknown"]), + ("harnesses", "default", []), + ("harnesses", "default", ["codex", "codex"]), + ("harnesses", "default", ["claude_sdk"]), + ("default_harness", "default", "unknown"), + ): + changed = copy.deepcopy(document) + changed["properties"]["core"]["properties"][key][field] = value + with self.subTest(key=key, field=field, value=value), self.assertRaises(ValueError): + catalog.check_installer_schema(entries, changed) + extended = [*entries, {"kind": "additional"}] + with self.assertRaises(ValueError): + catalog.check_installer_schema(extended, document) + + def test_installer_projection_includes_every_declaration(self): + providers = {"additional": {"responses": {"requires_token_limits": True}, + "anthropic": {"requires_token_limits": False}}, "native": {}} + namespace = {} + exec(catalog.render_installer(providers), namespace) + self.assertEqual(namespace["PROVIDERS"], providers) + def test_new_registration_reaches_all_projections(self): entries = [{"kind": "example", "label": "Example", "configuration": "example", "profile": "exampleProfile"}] diff --git a/scripts/harness-catalog/main.go b/scripts/harness-catalog/main.go new file mode 100644 index 000000000..6ce96690f --- /dev/null +++ b/scripts/harness-catalog/main.go @@ -0,0 +1,31 @@ +// Command harness-catalog projects adapter-owned provider declarations for tooling. +package main + +import ( + "encoding/json" + "os" + + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" +) + +type provider struct { + RequiresTokenLimits bool `json:"requires_token_limits"` +} + +func declarations() map[string]map[string]provider { + result := make(map[string]map[string]provider) + for _, kind := range builtin.Kinds() { + providers := make(map[string]provider) + for _, declaration := range builtin.Configuration(kind).Providers { + providers[declaration.Protocol] = provider{RequiresTokenLimits: declaration.RequiresTokenLimits} + } + result[kind] = providers + } + return result +} + +func main() { + if err := json.NewEncoder(os.Stdout).Encode(declarations()); err != nil { + panic(err) + } +} diff --git a/scripts/harness-catalog/main_test.go b/scripts/harness-catalog/main_test.go new file mode 100644 index 000000000..db9cb748d --- /dev/null +++ b/scripts/harness-catalog/main_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" +) + +func TestProjectionMatchesEveryAdapterDeclaration(t *testing.T) { + got := declarations() + if len(got) != len(builtin.Kinds()) { + t.Fatal("projection membership differs from catalog") + } + for _, kind := range builtin.Kinds() { + configuration := builtin.Configuration(kind) + providers, ok := got[kind] + if !ok || len(providers) != len(configuration.Providers) { + t.Fatalf("%s: missing provider declarations", kind) + } + for _, declaration := range configuration.Providers { + projected, ok := providers[declaration.Protocol] + if !ok || projected.RequiresTokenLimits != declaration.RequiresTokenLimits { + t.Fatalf("%s/%s: configuration rule differs", kind, declaration.Protocol) + } + } + } +}