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
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
paths:
- docs/**
- internal/api/openapi.json
- schemas/**
- scripts/docs_schema_test.go
- Makefile
- scripts/hugo.sh
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/manual/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
93 changes: 93 additions & 0 deletions docs/content/docs/manual/json-schemas.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/hugo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
162 changes: 162 additions & 0 deletions internal/config/schema_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
18 changes: 11 additions & 7 deletions internal/devcontainer/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions internal/devcontainer/schema_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading