From ca3c1f503fa2b88412f1cbe9d30b44ead760165a Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 9 Oct 2026 12:02:33 +0200 Subject: [PATCH 1/3] feat(config): add config.json JSON Schema and accept $schema The configuration is not stable, so #268 maps one explicitly provisional, named version: v0-provisional. The schema is hand-written in schemas/config.v0-provisional.schema.json and checks structure only (keys, types, enums, ranges); ownership, permissions and runtime policy stay in Validate and whr doctor. config.Parse keeps DisallowUnknownFields. Only an optional string key $schema is added, as Config.Schema, so a file that names its schema for editors still loads while typos and secret-field rejection are unchanged. Nothing reads or resolves the value, so no network request can happen and a missing, wrong or unreachable URL never affects loading; a test pins that by structure, since a call cannot be proven absent otherwise. Two $schema meanings stay apart: the schema document names the JSON Schema dialect, an instance names the published schema URL (a test checks both). internal/schematest holds a small validator and a recursive comparison of a schema with a Go struct (keys both ways, nested, types, optionality). I wrote a walker instead of adding a validator dependency: it supports only the keywords the schemas use and panics on any other, so nothing in a schema goes unchecked. The one deliberate exception is clone_depth, which the loader reads as 0 when missing. Refs: #268 Co-Authored-By: Claude Sonnet 5.5 --- internal/config/config.go | 4 + internal/config/schema_test.go | 162 ++++++++++++ internal/schematest/schematest.go | 291 ++++++++++++++++++++++ internal/schematest/schematest_test.go | 72 ++++++ schemas/config.v0-provisional.schema.json | 182 ++++++++++++++ 5 files changed, 711 insertions(+) create mode 100644 internal/config/schema_test.go create mode 100644 internal/schematest/schematest.go create mode 100644 internal/schematest/schematest_test.go create mode 100644 schemas/config.v0-provisional.schema.json diff --git a/internal/config/config.go b/internal/config/config.go index 550fbb15..b4f06649 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -199,6 +199,10 @@ func APISocketPath(stateDir, home string) string { // Config is the whole file. type Config struct { + // Schema is the optional editor hint `$schema`, the URL of the published JSON + // Schema (issue #268). It is data only: nothing resolves, fetches or checks + // it, and its value never affects loading or validation. + Schema string `json:"$schema,omitempty"` SkillSet skillset.Config `json:"skill_set,omitzero"` // Listen is the address `whr serve` binds: a loopback address (D29). Listen string `json:"listen"` diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go new file mode 100644 index 00000000..18aee262 --- /dev/null +++ b/internal/config/schema_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/wstein/workharbor/internal/schematest" +) + +const ( + configSchemaPath = "../../schemas/config.v0-provisional.schema.json" + // The URL an instance names (editors) and the dialect a schema document + // names are different things and must stay different. + configSchemaURL = "https://wstein.github.io/workharbor/schemas/config.v0-provisional.schema.json" + dialectURL = "https://json-schema.org/draft/2020-12/schema" +) + +// TestConfigSchemaMatchesTheStruct fails when a key (at any depth) exists on +// one side only, or its type or optionality differs. +func TestConfigSchemaMatchesTheStruct(t *testing.T) { + s := schematest.Load(t, configSchemaPath) + schematest.CompareStruct(t, s, reflect.TypeFor[Config](), map[string]string{ + "$.repositories[].clone_depth": "the loader reads a missing clone_depth as 0 (full history), and the manual's example omits it", + }) +} + +func TestSchemaDocumentAndInstanceHintAreDistinct(t *testing.T) { + s := schematest.Load(t, configSchemaPath) + if s["$schema"] != dialectURL { + t.Errorf("$schema of the schema document = %v, want the JSON Schema dialect %s", s["$schema"], dialectURL) + } + if s["$id"] != configSchemaURL { + t.Errorf("$id = %v, want the published URL %s", s["$id"], configSchemaURL) + } +} + +// manualExample returns the config.json example of the host setup page. +func manualExample(t *testing.T) []byte { + t.Helper() + page, err := os.ReadFile("../../docs/content/docs/manual/host-setup.md") + if err != nil { + t.Fatal(err) + } + _, rest, ok := strings.Cut(string(page), "```json\n") + for ok && !strings.Contains(strings.SplitN(rest, "```", 2)[0], `"listen"`) { + _, rest, ok = strings.Cut(rest, "```json\n") + } + if !ok { + t.Fatal("no config.json example with a listen key in host-setup.md") + } + block, _, _ := strings.Cut(rest, "```") + var lines []string + for line := range strings.SplitSeq(block, "\n") { + lines = append(lines, strings.TrimPrefix(line, " ")) + } + return []byte(strings.Join(lines, "\n")) +} + +func TestSchemaAcceptsGoodExamplesAndRejectsWrongOnes(t *testing.T) { + s := schematest.Load(t, configSchemaPath) + example := manualExample(t) + if p := schematest.Validate(s, example); len(p) != 0 { + t.Fatalf("the manual's example does not validate: %v", p) + } + generated, _ := json.Marshal(newRig(t).cfg) + if p := schematest.Validate(s, generated); len(p) != 0 { + t.Fatalf("a marshalled Config does not validate: %v", p) + } + + mutate := func(edit func(m map[string]any)) []byte { + var m map[string]any + if err := json.Unmarshal(example, &m); err != nil { + t.Fatal(err) + } + edit(m) + raw, _ := json.Marshal(m) + return raw + } + nested := func(m map[string]any, key string) map[string]any { return m[key].(map[string]any) } + for name, tc := range map[string]struct { + doc []byte + want string + }{ + "nested typo": {mutate(func(m map[string]any) { nested(m, "github")["app_idd"] = 1 }), `$.github: unknown key "app_idd"`}, + "top-level typo": {mutate(func(m map[string]any) { m["listn"] = "x" }), `unknown key "listn"`}, + "wrong type": {mutate(func(m map[string]any) { nested(m, "github")["app_id"] = "123456" }), "$.github.app_id: want integer"}, + "missing": {mutate(func(m map[string]any) { delete(nested(m, "roots"), "tool_store") }), `missing required key "tool_store"`}, + "enum": {mutate(func(m map[string]any) { m["account"] = "private" }), "$.account: "}, + "enum in a list": {mutate(func(m map[string]any) { m["repositories"].([]any)[0].(map[string]any)["workflow"] = "yolo" }), "$.repositories[0].workflow: "}, + "range": {mutate(func(m map[string]any) { m["preview"] = map[string]any{"first_port": 80, "last_port": 90} }), "$.preview.first_port: 80 violates minimum"}, + "percent": {mutate(func(m map[string]any) { m["budgets"] = map[string]any{"soft_percent": 100} }), "$.budgets.soft_percent: 100 violates maximum"}, + "list item type": {mutate(func(m map[string]any) { m["agent_allowed_tools"] = []any{"Read", 7} }), "$.agent_allowed_tools[1]: want string"}, + "empty workspace": {mutate(func(m map[string]any) { nested(m, "roots")["workspaces"] = []any{} }), "fewer than 1 items"}, + } { + t.Run(name, func(t *testing.T) { + got := strings.Join(schematest.Validate(s, tc.doc), "\n") + if !strings.Contains(got, tc.want) { + t.Errorf("problems = %q, want one containing %q", got, tc.want) + } + }) + } +} + +// TestSchemaKeyIsDataOnly pins the rule of issue #268. Parse decodes the file +// with the Go struct and nothing else: the only place that ever sees the value +// is Config.Schema, and no code in this package imports net/http or net, opens a +// connection or hands the value to anything. Asserting "no request is made" +// otherwise is impossible from a test, so the test pins the structure: the +// value does not change the result, and the field is read nowhere. +func TestSchemaKeyIsDataOnly(t *testing.T) { + r := newRig(t) + base, err := json.Marshal(r.cfg) + if err != nil { + t.Fatal(err) + } + for _, url := range []string{configSchemaURL, "", "https://unreachable.invalid/x.json", "not a url at all", "file:///nonexistent", "http://127.0.0.1:1/never"} { + var m map[string]any + _ = json.Unmarshal(base, &m) + m["$schema"] = url + raw, _ := json.Marshal(m) + c, err := Parse(raw) + if err != nil { + t.Fatalf("$schema %q broke loading: %v", url, err) + } + if c.Schema != url { + t.Errorf("Schema = %q, want %q kept as data", c.Schema, url) + } + } + + var m map[string]any + _ = json.Unmarshal(base, &m) + m["$schema"] = 7 + raw, _ := json.Marshal(m) + if _, err := Parse(raw); err == nil { + t.Error("a non-string $schema must fail: it is an optional string key") + } + + m["$schema"] = configSchemaURL + m["$schemas"] = "typo" + raw, _ = json.Marshal(m) + if _, err := Parse(raw); err == nil || !strings.Contains(err.Error(), "$schemas") { + t.Errorf("an unknown key next to $schema = %v, want it refused by name", err) + } + + // Nothing in the package may resolve the URL. + entries, _ := filepath.Glob("*.go") + for _, f := range entries { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, _ := os.ReadFile(f) //nolint:gosec // package source + for _, banned := range []string{`"net/http"`, ".Schema"} { + if strings.Contains(string(src), banned) { + t.Errorf("%s uses %s: a $schema value must never be resolved or read", f, banned) + } + } + } +} diff --git a/internal/schematest/schematest.go b/internal/schematest/schematest.go new file mode 100644 index 00000000..06938ab7 --- /dev/null +++ b/internal/schematest/schematest.go @@ -0,0 +1,291 @@ +// Package schematest is the test support for the hand-written JSON Schemas in +// schemas/ (issue #268). It holds a deliberately small validator and a +// recursive comparison of a schema with a Go struct. It supports only the +// keywords the schemas use and fails on any other, so a schema cannot carry a +// constraint that nothing checks. It is not linked into the product. +package schematest + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "regexp" + "slices" + "sort" + "strings" + "testing" +) + +// Schema is a parsed JSON Schema document. +type Schema = map[string]any + +type object = Schema + +// annotations are keywords that describe and never constrain. +var annotations = []string{"$schema", "$id", "title", "description", "$comment", "default", "examples", "$defs"} + +// Load reads and parses a schema file. +func Load(t testing.TB, path string) Schema { + t.Helper() + raw, err := os.ReadFile(path) //nolint:gosec // test input + if err != nil { + t.Fatal(err) + } + var s Schema + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatalf("%s: %v", path, err) + } + return s +} + +// Validate returns what is wrong with the JSON document raw, one line per +// problem with its path, against the schema root. +func Validate(root Schema, raw []byte) []string { + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + return []string{"not JSON: " + err.Error()} + } + var problems []string + validate(root, root, doc, "$", &problems) + return problems +} + +func resolve(root, s object) object { + ref, ok := s["$ref"].(string) + if !ok { + return s + } + name, found := strings.CutPrefix(ref, "#/$defs/") + defs, _ := root["$defs"].(object) + target, _ := defs[name].(object) + if !found || target == nil { + panic("schematest: unresolved $ref " + ref) + } + return target +} + +func validate(root, s object, v any, path string, out *[]string) { + s = resolve(root, s) + bad := func(format string, args ...any) { *out = append(*out, path+": "+fmt.Sprintf(format, args...)) } + keys := make([]string, 0, len(s)) + for k := range s { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if slices.Contains(annotations, k) { + continue + } + switch k { + case "type": + if !typeMatches(s[k].(string), v) { + bad("want %s, got %s", s[k], jsonType(v)) + return + } + case "enum": + if !slices.ContainsFunc(s[k].([]any), func(e any) bool { return reflect.DeepEqual(e, v) }) { + bad("%v is not one of %v", v, s[k]) + } + case "minimum", "maximum": + if n, ok := v.(float64); ok && (k == "minimum" && n < s[k].(float64) || k == "maximum" && n > s[k].(float64)) { + bad("%v violates %s %v", n, k, s[k]) + } + case "maxLength": + if str, ok := v.(string); ok && float64(len(str)) > s[k].(float64) { + bad("longer than %v", s[k]) + } + case "pattern": + if str, ok := v.(string); ok && !regexp.MustCompile(s[k].(string)).MatchString(str) { + bad("%q does not match %s", str, s[k]) + } + case "minItems": + if a, ok := v.([]any); ok && float64(len(a)) < s[k].(float64) { + bad("fewer than %v items", s[k]) + } + case "items": + if a, ok := v.([]any); ok { + for i, e := range a { + validate(root, s[k].(object), e, fmt.Sprintf("%s[%d]", path, i), out) + } + } + case "anyOf": + ok := false + for _, alt := range s[k].([]any) { + var sub []string + validate(root, alt.(object), v, path, &sub) + ok = ok || len(sub) == 0 + } + if !ok { + bad("matches none of the alternatives") + } + case "required": + if m, isObj := v.(object); isObj { + for _, name := range s[k].([]any) { + if _, has := m[name.(string)]; !has { + bad("missing required key %q", name) + } + } + } + case "additionalProperties": + if m, isObj := v.(object); isObj && s[k] == false { + props, _ := s["properties"].(object) + for name := range m { + if _, known := props[name]; !known { + bad("unknown key %q", name) + } + } + } + case "properties": + if m, isObj := v.(object); isObj { + for name, sub := range s[k].(object) { + if val, has := m[name]; has { + validate(root, sub.(object), val, path+"."+name, out) + } + } + } + case "$ref": + default: + panic("schematest: unsupported keyword " + k) + } + } +} + +func jsonType(v any) string { + switch x := v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case float64: + if x == float64(int64(x)) { + return "integer" + } + return "number" + case string: + return "string" + case []any: + return "array" + } + return "object" +} + +func typeMatches(want string, v any) bool { + got := jsonType(v) + return got == want || want == "number" && got == "integer" +} + +// CompareStruct fails t for every difference between the schema and the Go +// type: a key on one side only, another type, another optionality, or an object +// schema that does not forbid unknown keys. A field is optional in the schema +// exactly when its tag says omitempty or omitzero; extraOptional lists the +// other optional keys by path with the reason, and rawAny the paths of +// json.RawMessage fields, which only need an entry. It recurses through +// nested objects, arrays and pointers. +func CompareStruct(t testing.TB, root Schema, typ reflect.Type, extraOptional map[string]string, rawAny ...string) { + t.Helper() + compare(t, root, root, typ, "$", extraOptional, rawAny) +} + +var rawMessage = reflect.TypeFor[json.RawMessage]() + +func compare(t testing.TB, root, s object, typ reflect.Type, path string, extra map[string]string, raw []string) { + t.Helper() + s = resolve(root, s) + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if slices.Contains(raw, path) && typ == rawMessage { + return + } + var want string + switch typ.Kind() { //nolint:exhaustive // the kinds the configuration uses + case reflect.String: + want = "string" + case reflect.Bool: + want = "boolean" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + want = "integer" + case reflect.Float32, reflect.Float64: + want = "number" + case reflect.Slice: + want = "array" + case reflect.Struct: + want = "object" + default: + t.Errorf("%s: Go kind %s is not supported by the comparison", path, typ.Kind()) + return + } + if got, _ := s["type"].(string); got != want { + t.Errorf("%s: the Go type %s is a JSON %s, the schema says %q", path, typ, want, got) + return + } + switch want { + case "array": + items, ok := s["items"].(object) + if !ok { + t.Errorf("%s: array schema has no items", path) + return + } + compare(t, root, items, typ.Elem(), path+"[]", extra, raw) + case "object": + compareObject(t, root, s, typ, path, extra, raw) + } +} + +func compareObject(t testing.TB, root, s object, typ reflect.Type, path string, extra map[string]string, raw []string) { + t.Helper() + if s["additionalProperties"] != false { + t.Errorf("%s: the schema must set additionalProperties to false", path) + } + props, _ := s["properties"].(object) + required := map[string]bool{} + for _, name := range asStrings(s["required"]) { + required[name] = true + } + seen := map[string]bool{} + for i := range typ.NumField() { + f := typ.Field(i) + name, opts, _ := strings.Cut(f.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + seen[name] = true + key := path + "." + name + sub, ok := props[name].(object) + if !ok { + t.Errorf("%s: the Go struct %s has this key, the schema has no entry", key, typ) + continue + } + optional := strings.Contains(opts, "omitempty") || strings.Contains(opts, "omitzero") + _, listed := extra[key] + switch { + case optional && required[name]: + t.Errorf("%s: omitempty in Go but required in the schema", key) + case !optional && !listed && !required[name]: + t.Errorf("%s: always written in Go but optional in the schema (list it with a reason if the loader accepts it missing)", key) + case listed && (optional || required[name]): + t.Errorf("%s: listed as a deliberate exception but it is not one", key) + } + compare(t, root, sub, f.Type, key, extra, raw) + } + for name := range props { + if !seen[name] { + t.Errorf("%s.%s: the schema has this entry, the Go struct %s has no such key", path, name, typ) + } + } + for name := range required { + if _, ok := props[name]; !ok { + t.Errorf("%s: required key %q has no property entry", path, name) + } + } +} + +func asStrings(v any) []string { + var out []string + list, _ := v.([]any) + for _, e := range list { + out = append(out, e.(string)) + } + return out +} diff --git a/internal/schematest/schematest_test.go b/internal/schematest/schematest_test.go new file mode 100644 index 00000000..92bef269 --- /dev/null +++ b/internal/schematest/schematest_test.go @@ -0,0 +1,72 @@ +package schematest + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +type nested struct { + N int `json:"n"` +} + +type sample struct { + Name string `json:"name"` + Opt []nested `json:"opt,omitempty"` +} + +func schema(t *testing.T, s string) object { + t.Helper() + var o object + if err := json.Unmarshal([]byte(s), &o); err != nil { + t.Fatal(err) + } + return o +} + +func TestValidateFindsNestedAndTypeProblems(t *testing.T) { + s := schema(t, `{"type":"object","additionalProperties":false,"required":["name"],"properties":{"name":{"type":"string","enum":["a"]},"opt":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"n":{"type":"integer","minimum":1}}}}}}`) + if p := Validate(s, []byte(`{"name":"a","opt":[{"n":2}]}`)); len(p) != 0 { + t.Fatalf("valid document: %v", p) + } + got := strings.Join(Validate(s, []byte(`{"name":"b","opt":[{"m":1,"n":0}]}`)), "\n") + for _, want := range []string{"$.name: ", "$.opt[0]: unknown key \"m\"", "$.opt[0].n: 0 violates minimum"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +func TestUnsupportedKeywordPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("an unsupported keyword must not pass silently") + } + }() + Validate(schema(t, `{"type":"object","oneOf":[]}`), []byte(`{}`)) +} + +func TestCompareStructReportsBothDirections(t *testing.T) { + rec := &recorder{} + s := schema(t, `{"type":"object","additionalProperties":false,"required":["name"],"properties":{"name":{"type":"string"},"opt":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{}}},"extra":{"type":"string"}}}`) + CompareStruct(rec, s, reflect.TypeFor[sample](), nil) + got := strings.Join(rec.errs, "\n") + for _, want := range []string{"$.opt[].n: the Go struct", "$.extra: the schema has this entry"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in\n%s", want, got) + } + } +} + +type recorder struct { + testing.TB + errs []string +} + +func (r *recorder) Helper() {} + +func (r *recorder) Errorf(format string, args ...any) { + r.errs = append(r.errs, fmt.Sprintf(format, args...)) +} diff --git a/schemas/config.v0-provisional.schema.json b/schemas/config.v0-provisional.schema.json new file mode 100644 index 00000000..a6d4454b --- /dev/null +++ b/schemas/config.v0-provisional.schema.json @@ -0,0 +1,182 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wstein.github.io/workharbor/schemas/config.v0-provisional.schema.json", + "title": "WorkHarbor config.json (v0-provisional)", + "description": "PROVISIONAL, hand-written editor aid for the supervisor's config.json. It checks structure only (keys, types, enums, ranges). Ownership, permissions and runtime policy are decided by `whr doctor` and `whr serve`, never by this schema. The configuration is not stable before the beta; this version is immutable once published and later versions get a new name.", + "type": "object", + "additionalProperties": false, + "required": ["listen", "repositories", "roots", "github", "api_token_file"], + "properties": { + "$schema": { + "type": "string", + "description": "Editor hint naming this schema. The loader ignores it and never fetches it." + }, + "skill_set": { + "type": "object", + "additionalProperties": false, + "properties": { + "store": { "type": "string" }, + "selection": { "type": "string", "enum": ["none", "default", "package"] }, + "default": { "$ref": "#/$defs/skillPin" }, + "package": { "$ref": "#/$defs/skillPin" } + } + }, + "listen": { "type": "string", "description": "Loopback address of the web UI, for example 127.0.0.1:8787." }, + "public_url": { "type": "string", "pattern": "^https://" }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "owner/name" }, + "clone_depth": { "type": "integer", "minimum": 0, "description": "0 keeps the full history. The loader accepts the key missing and reads it as 0." }, + "workflow": { "type": "string", "enum": ["prototype", "integration", "published"] }, + "integration_branch": { "type": "string" }, + "check": { "type": "string", "maxLength": 4096 }, + "commit_lint": { "type": "string", "enum": ["conventional", "workharbor"] } + } + } + }, + "roots": { + "type": "object", + "additionalProperties": false, + "required": ["workspaces", "tool_store"], + "properties": { + "workspaces": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "tool_store": { "type": "string" } + } + }, + "github": { + "type": "object", + "additionalProperties": false, + "required": ["app_id", "key_file"], + "properties": { + "app_id": { "type": "integer", "minimum": 1 }, + "key_file": { "type": "string" }, + "api_url": { "type": "string" } + } + }, + "agent_api_key_env_file": { "type": "string" }, + "bot_signing_key_file": { "type": "string" }, + "api_token_file": { "type": "string" }, + "api_clients": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "token_file"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$" }, + "token_file": { "type": "string" } + } + } + }, + "state_dir": { "type": "string" }, + "environment": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { "type": "string" }, + "base": { "type": "string", "enum": ["fedora", "ubuntu"] }, + "egress_allow": { "type": "array", "items": { "type": "string" } }, + "cpus": { "type": "integer", "minimum": 0 }, + "memory_mb": { "type": "integer", "minimum": 0 }, + "disk_mb": { "type": "integer", "minimum": 0 }, + "post_create_timeout": { "type": "string", "description": "A Go duration from 1s to 6h, such as 10m; the range is checked by the loader." } + } + }, + "console": { + "type": "object", + "additionalProperties": false, + "properties": { + "egress_allow": { "type": "array", "items": { "type": "string" } }, + "cpus": { "type": "integer", "minimum": 0 }, + "memory_mb": { "type": "integer", "minimum": 0 }, + "disk_mb": { "type": "integer", "minimum": 0 }, + "ssh_ca_key_file": { "type": "string" }, + "base": { "type": "string", "enum": ["fedora", "ubuntu", "alpine"] } + } + }, + "budgets": { + "type": "object", + "additionalProperties": false, + "properties": { + "per_run": { "$ref": "#/$defs/budgetLimit" }, + "per_task": { "$ref": "#/$defs/budgetLimit" }, + "soft_percent": { "type": "integer", "minimum": 1, "maximum": 99 } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "warn_percent": { "type": "integer", "minimum": 1, "maximum": 99 }, + "low_balance_usd": { "type": "number", "minimum": 0 } + } + }, + "preview": { + "type": "object", + "additionalProperties": false, + "properties": { + "first_port": { "type": "integer", "minimum": 1024, "maximum": 65535 }, + "last_port": { "type": "integer", "minimum": 1024, "maximum": 65535 } + } + }, + "tool_profile": { "type": "string" }, + "board": { + "type": "object", + "additionalProperties": false, + "required": ["owner", "number"], + "properties": { + "owner": { "type": "string" }, + "number": { "type": "integer", "minimum": 1 }, + "organization": { "type": "boolean" }, + "status_field": { "type": "string" }, + "session_field": { "type": "string" }, + "link_field": { "type": "string" }, + "queue_status": { "type": "string" }, + "public_url": { "type": "string", "pattern": "^https://" } + } + }, + "ntfy": { + "type": "object", + "additionalProperties": false, + "required": ["topic_file"], + "properties": { + "server": { "type": "string" }, + "topic_file": { "type": "string" }, + "token_file": { "type": "string" } + } + }, + "account": { "type": "string", "enum": ["dedicated", "shared"] }, + "development_prefix": { "type": "string", "description": "Retired; accepted so older files load, and ignored." }, + "agent_permission_mode": { "type": "string", "enum": ["dontAsk", "manual"] }, + "agent_allowed_tools": { "type": "array", "items": { "type": "string" } } + }, + "$defs": { + "budgetLimit": { + "type": "object", + "additionalProperties": false, + "properties": { + "max_duration": { "type": "string" }, + "max_tokens": { "type": "integer", "minimum": 0 }, + "max_cost_usd": { "type": "number", "minimum": 0 } + } + }, + "skillPin": { + "type": "object", + "additionalProperties": false, + "required": ["identity", "source", "commit", "manifest_sha256", "inventory_sha256", "contract_version"], + "properties": { + "identity": { "type": "string" }, + "source": { "type": "string" }, + "commit": { "type": "string" }, + "manifest_sha256": { "type": "string" }, + "inventory_sha256": { "type": "string" }, + "contract_version": { "type": "integer", "enum": [1] } + } + } + } +} From 7bee4ba4169a8fd98d70e52b423c4df35e859722 Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 9 Oct 2026 12:02:55 +0200 Subject: [PATCH 2/3] feat(devcontainer): add JSON Schema for customizations.workharbor D51's check and the other hint keys live in a free-form customizations block, so an editor cannot complete or check them. Add the hand-written schemas/devcontainer-workharbor.v0-provisional.schema.json (same provisional name as the config schema: v0-provisional). It is structural only; the supervisor's configuration still decides and the reader still ignores bad values with a note. It flags unknown keys so an editor shows a typo although the reader would ignore them. The reader's anonymous struct becomes the named workharborBlock so a test can compare it recursively with the schema, as it does for config.json (keys both ways, types, optionality). previewPorts stays a RawMessage because the reader accepts numbers and numeric strings; the schema allows both and the test only requires its entry. Good and wrong blocks (typo, wrong type, bad port) are validated with the same small walker. Refs: #268 Co-Authored-By: Claude Sonnet 5.5 --- internal/devcontainer/devcontainer.go | 18 ++++--- internal/devcontainer/schema_test.go | 47 +++++++++++++++++++ ...iner-workharbor.v0-provisional.schema.json | 27 +++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 internal/devcontainer/schema_test.go create mode 100644 schemas/devcontainer-workharbor.v0-provisional.schema.json diff --git a/internal/devcontainer/devcontainer.go b/internal/devcontainer/devcontainer.go index fc90946e..f19fdc2c 100644 --- a/internal/devcontainer/devcontainer.go +++ b/internal/devcontainer/devcontainer.go @@ -436,6 +436,16 @@ func ports(key string, v json.RawMessage, notes []string) ([]int, []string) { // ValidHost reports whether name can be requested as an egress host. func ValidHost(name string) bool { return domain.ValidHost(name) } +// workharborBlock is the value of customizations.workharbor. A test compares it +// with schemas/devcontainer-workharbor.v0-provisional.schema.json. +type workharborBlock struct { + Egress []string `json:"egress,omitempty"` + Check string `json:"check,omitempty"` + PreviewPorts json.RawMessage `json:"previewPorts,omitempty"` + Agent string `json:"agent,omitempty"` + Tools []string `json:"tools,omitempty"` +} + func customizations(v json.RawMessage, notes []string) ([]string, Hints, []string) { var c map[string]json.RawMessage if json.Unmarshal(v, &c) != nil { @@ -448,13 +458,7 @@ func customizations(v json.RawMessage, notes []string) ([]string, Hints, []strin notes = append(notes, "customizations."+tool+": ignored") continue } - var w struct { - Egress []string `json:"egress"` - Check string `json:"check"` - PreviewPorts json.RawMessage `json:"previewPorts"` - Agent string `json:"agent"` - Tools []string `json:"tools"` - } + var w workharborBlock if err := json.Unmarshal(body, &w); err != nil { notes = append(notes, "customizations.workharbor: unreadable, ignored") continue diff --git a/internal/devcontainer/schema_test.go b/internal/devcontainer/schema_test.go new file mode 100644 index 00000000..dcc85155 --- /dev/null +++ b/internal/devcontainer/schema_test.go @@ -0,0 +1,47 @@ +package devcontainer + +import ( + "reflect" + "strings" + "testing" + + "github.com/wstein/workharbor/internal/schematest" +) + +const blockSchemaPath = "../../schemas/devcontainer-workharbor.v0-provisional.schema.json" + +// TestBlockSchemaMatchesTheStruct fails when a key of customizations.workharbor +// exists in the Go reader or in the schema only, or their types differ. +func TestBlockSchemaMatchesTheStruct(t *testing.T) { + s := schematest.Load(t, blockSchemaPath) + // previewPorts is read leniently (numbers or numeric strings), so the Go + // field is a json.RawMessage and only needs a schema entry. + schematest.CompareStruct(t, s, reflect.TypeFor[workharborBlock](), nil, "$.previewPorts") +} + +func TestBlockSchemaAcceptsAGoodBlockAndRejectsWrongOnes(t *testing.T) { + s := schematest.Load(t, blockSchemaPath) + good := `{"egress":["registry.npmjs.org"],"check":"make check","previewPorts":[3000,"8080"],"agent":"claude","tools":["Read","Edit"]}` + if p := schematest.Validate(s, []byte(good)); len(p) != 0 { + t.Fatalf("a good block does not validate: %v", p) + } + // The same block must be read by the product without notes. + if _, _, notes := customizations([]byte(`{"workharbor":`+good+`}`), nil); len(notes) != 0 { + t.Fatalf("the good example produced notes: %v", notes) + } + for name, tc := range map[string]struct{ doc, want string }{ + "typo": {`{"cmd_check":"make check"}`, `unknown key "cmd_check"`}, + "wrong type": {`{"check":["make","check"]}`, "$.check: want string"}, + "list item type": {`{"egress":["a.example", 3]}`, "$.egress[1]: want string"}, + "port out": {`{"previewPorts":[70000]}`, "$.previewPorts[0]: matches none"}, + "port not number": {`{"previewPorts":["http"]}`, "$.previewPorts[0]: matches none"}, + "not an object": {`["check"]`, "want object"}, + } { + t.Run(name, func(t *testing.T) { + got := strings.Join(schematest.Validate(s, []byte(tc.doc)), "\n") + if !strings.Contains(got, tc.want) { + t.Errorf("problems = %q, want one containing %q", got, tc.want) + } + }) + } +} diff --git a/schemas/devcontainer-workharbor.v0-provisional.schema.json b/schemas/devcontainer-workharbor.v0-provisional.schema.json new file mode 100644 index 00000000..428a17f3 --- /dev/null +++ b/schemas/devcontainer-workharbor.v0-provisional.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wstein.github.io/workharbor/schemas/devcontainer-workharbor.v0-provisional.schema.json", + "title": "devcontainer.json customizations.workharbor (v0-provisional)", + "description": "PROVISIONAL, hand-written editor aid for the value of customizations.workharbor in a repository's devcontainer.json. These keys only suggest: the supervisor's own configuration decides, and a bad value is ignored with a note rather than refused. This schema checks structure only and flags unknown keys so an editor shows a typo, although the loader ignores them. This version is immutable once published and later versions get a new name.", + "type": "object", + "additionalProperties": false, + "properties": { + "egress": { + "type": "array", + "items": { "type": "string" }, + "description": "Exact host names the repository asks to reach. A wildcard is refused; the human answers each as a Decision." + }, + "check": { "type": "string", "description": "The command that checks the work before a review is asked for. The supervisor's own configuration wins." }, + "previewPorts": { + "type": "array", + "items": { + "anyOf": [ + { "type": "integer", "minimum": 1, "maximum": 65535 }, + { "type": "string", "pattern": "^[0-9]{1,5}$" } + ] + } + }, + "agent": { "type": "string", "description": "The name of the agent to prefer." }, + "tools": { "type": "array", "items": { "type": "string" }, "description": "Tools to allow the agent, by name." } + } +} From 8c2d39caf5c9ec5a933dea3c2a0b65e3b16365bf Mon Sep 17 00:00:00 2001 From: Werner Stein Date: Fri, 9 Oct 2026 12:03:39 +0200 Subject: [PATCH 3/3] docs(schemas): publish the JSON Schemas with the docs site #268 asks for the schemas next to openapi.json. Mount the repository's schemas/ directory into the site at /schemas/, as #267 does for openapi.json: one canonical source, copied during the docs build, with no second copy. The pages workflow now also runs when schemas/ changes. Each file name carries its surface and version (.v0-provisional.schema.json), so a later version gets a new URL and older files stay where they are. A docs test checks that every file of schemas/ is published byte for byte and that its name has that shape; that a published file is never edited stays a review rule, not a test. The new manual page JSON Schemas explains the provisional marking, the $schema line (ignored by the loader, never fetched) and one editor association for the devcontainer block (a VS Code json.schemas wrapper). Whether VS Code resolves that remote $ref is marked unverified. The docs test helper builds the site once for all tests of the package. Refs: #268 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/pages.yml | 1 + docs/content/docs/manual/_index.md | 1 + docs/content/docs/manual/json-schemas.md | 93 ++++++++++++++++++++++++ docs/hugo.yaml | 2 + scripts/docs_schema_test.go | 72 ++++++++++++++++-- 5 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 docs/content/docs/manual/json-schemas.md diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index cb1f8973..02054fb6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,7 @@ on: paths: - docs/** - internal/api/openapi.json + - schemas/** - scripts/docs_schema_test.go - Makefile - scripts/hugo.sh diff --git a/docs/content/docs/manual/_index.md b/docs/content/docs/manual/_index.md index 7984869e..4a657880 100644 --- a/docs/content/docs/manual/_index.md +++ b/docs/content/docs/manual/_index.md @@ -17,6 +17,7 @@ How to set up and run WorkHarbor. WorkHarbor is building release 1. The service {{< card link="sessions-and-agents" title="Sessions and agents" subtitle="Which sessions to keep open, their models, the review gate (draft)." icon="users" >}} {{< card link="gh-stack" title="Stack pull requests" subtitle="Related issues as a gh stack: who runs what, adopting branches (provisional)." icon="collection" >}} {{< card link="operations-digest" title="Operations digest" subtitle="An index of decisions, procedures, how-tos and known gaps, linked to their sources." icon="collection" >}} + {{< card link="json-schemas" title="JSON Schemas" subtitle="Provisional editor schemas for config.json and the devcontainer block." icon="document-text" >}} {{< /cards >}} The drafts are checked against the first release, `v0.1.0` (issue #65). diff --git a/docs/content/docs/manual/json-schemas.md b/docs/content/docs/manual/json-schemas.md new file mode 100644 index 00000000..83e71538 --- /dev/null +++ b/docs/content/docs/manual/json-schemas.md @@ -0,0 +1,93 @@ +--- +title: JSON Schemas +description: Provisional JSON Schemas for config.json and the devcontainer customizations.workharbor block, for editor completion. +weight: 13 +toc: true +--- + +{{< status unverified >}} **Provisional until the beta: the configuration is not +stable, and so are these schemas.** They are an editor aid for catching a typo +or a wrong type before `whr doctor` does. They never decide anything: `whr +doctor` and `whr serve` decide whether a configuration is valid, including +file ownership, permissions and every runtime rule. A schema checks structure +only: keys, types, enums and plain ranges. + +## The published schemas + +The docs site publishes the hand-written schemas from the repository's +`schemas/` directory, copied unchanged during the docs build (a test fails when +the published file differs from the source), next to +[`openapi.json`](api-reference.md). + +| Surface | URL | +| --- | --- | +| `config.json` | `https://wstein.github.io/workharbor/schemas/config.v0-provisional.schema.json` | +| `customizations.workharbor` in `devcontainer.json` | `https://wstein.github.io/workharbor/schemas/devcontainer-workharbor.v0-provisional.schema.json` | + +**Versions.** `v0-provisional` is the name of the configuration these files +map: the known structure as of 2026-10. A published file never changes. When the +configuration changes, the schema gets a new name (`v1-provisional`, later a +stable `v1`) and the old file and its URL stay, so a file that names an older +version keeps working in an editor. That the published file is never edited is +a rule of review; no test checks it against history. + +**How they are kept honest.** A Go test compares each schema recursively with +the Go struct the product reads (every key at every depth on both sides, types +and optionality), and fails with the key's path. Further tests validate the +manual's example and deliberately wrong ones (a nested typo, a wrong type, an +unknown enum value, a port out of range). Schemas are written by hand; there is +no generator. + +## `$schema` in config.json + +Add the URL as the first key to get completion and warnings in an editor that +reads `$schema` (many do for JSON files): + +```json +{ + "$schema": "https://wstein.github.io/workharbor/schemas/config.v0-provisional.schema.json", + "listen": "127.0.0.1:8787" +} +``` + +The loader accepts `$schema` as an optional string and ignores it. Nothing in +WorkHarbor downloads or checks the URL, so the supervisor works offline and a +missing, wrong or unreachable value never changes loading. Every other unknown +key is still refused by name. (In the schema document itself, `$schema` means +something else: the JSON Schema dialect, a different URL.) `whr setup` does not +write the line yet; that is a separate issue (#269). + +## The `customizations.workharbor` block + +An editor only validates a whole file against a schema, and the devcontainer +specification keeps `customizations` free-form, so the block schema is attached +to the path `customizations.workharbor` through a wrapper. With VS Code, put +this in the repository's `.vscode/settings.json`: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["devcontainer.json", ".devcontainer.json"], + "schema": { + "properties": { + "customizations": { + "properties": { + "workharbor": { + "$ref": "https://wstein.github.io/workharbor/schemas/devcontainer-workharbor.v0-provisional.schema.json" + } + } + } + } + } + } + ] +} +``` + +Whether VS Code fetches the remote `$ref`, and whether this combines with the +devcontainer schema the editor already applies, were not measured here +({{< status unverified >}}). Other editors were not tried. Only the schema file +itself is tested. The keys describe hints: the supervisor's own configuration +decides, and a repository can only ask (D38), so a value that passes the schema +can still be ignored with a note. diff --git a/docs/hugo.yaml b/docs/hugo.yaml index 2a239704..e4764f45 100644 --- a/docs/hugo.yaml +++ b/docs/hugo.yaml @@ -9,6 +9,8 @@ module: target: static - source: ../internal/api/openapi.json target: static/openapi.json + - source: ../schemas + target: static/schemas - source: assets target: assets - source: ../internal/api/openapi.json diff --git a/scripts/docs_schema_test.go b/scripts/docs_schema_test.go index 577d2bfa..f8bba208 100644 --- a/scripts/docs_schema_test.go +++ b/scripts/docs_schema_test.go @@ -2,24 +2,43 @@ package scripts_test import ( "bytes" + "context" "encoding/json" "flag" + "fmt" "html" "os" "os/exec" "path/filepath" + "regexp" "strings" + "sync" "testing" ) var docsSite = flag.String("docs-site", "", "already built documentation site") -// TestDocsSchema checks the actual build artifact, not a second tracked schema. -func TestDocsSchema(t *testing.T) { - site := *docsSite - if site == "" { - site = t.TempDir() - cmd := exec.CommandContext(t.Context(), "make", "docs-build", "DOCS_DEST="+site) //nolint:gosec // destination is created by t.TempDir +var ( + siteOnce sync.Once + siteDir string + siteErr error +) + +// builtSite returns the already built site of -docs-site, else builds it once +// for all tests of this package. +func builtSite(t *testing.T) string { + t.Helper() + if *docsSite != "" { + return *docsSite + } + siteOnce.Do(func() { + dir, err := os.MkdirTemp("", "docs-site-") + if err != nil { + siteErr = err + return + } + siteDir = dir + cmd := exec.CommandContext(context.Background(), "make", "docs-build", "DOCS_DEST="+dir) //nolint:gosec // destination is created by MkdirTemp cmd.Dir = ".." // Keep the tool caches and HOME; isolate git invoked by make or Hugo. for _, entry := range os.Environ() { @@ -30,9 +49,18 @@ func TestDocsSchema(t *testing.T) { } cmd.Env = append(cmd.Env, "GIT_CONFIG_SYSTEM=/dev/null", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=", "GIT_TERMINAL_PROMPT=0", "SSH_AUTH_SOCK=", "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o IdentityAgent=none") if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("build documentation: %v\n%s", err, out) + siteErr = fmt.Errorf("build documentation: %w\n%s", err, out) } + }) + if siteErr != nil { + t.Fatal(siteErr) } + return siteDir +} + +// TestDocsSchema checks the actual build artifact, not a second tracked schema. +func TestDocsSchema(t *testing.T) { + site := builtSite(t) source, err := os.ReadFile("../internal/api/openapi.json") if err != nil { t.Fatal(err) @@ -76,3 +104,33 @@ func TestDocsSchema(t *testing.T) { } } } + +// TestDocsPublishesTheJSONSchemas checks that every file of schemas/ reaches +// the site unchanged at /schemas/ (issue #268), and that each name +// carries its surface and a version, so a historical file keeps its URL. +func TestDocsPublishesTheJSONSchemas(t *testing.T) { + site := builtSite(t) + names, err := filepath.Glob("../schemas/*") + if err != nil || len(names) == 0 { + t.Fatalf("no schemas to publish: %v", err) + } + name := regexp.MustCompile(`^[a-z-]+\.v[0-9]+(-provisional)?\.schema\.json$`) + for _, path := range names { + base := filepath.Base(path) + if !name.MatchString(base) { + t.Errorf("%s: want .v[-provisional].schema.json", base) + } + source, err := os.ReadFile(path) //nolint:gosec // repository file + if err != nil { + t.Fatal(err) + } + published, err := os.ReadFile(filepath.Join(site, "schemas", base)) //nolint:gosec // test build artifact + if err != nil { + t.Errorf("%s is not published: %v", base, err) + continue + } + if !bytes.Equal(source, published) { + t.Errorf("published %s differs from schemas/%s", base, base) + } + } +}