diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b5b4f4f..5a7af438 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,12 @@ jobs: pkg=github.com/MMinasyan/lightcode/internal/version $(go env GOPATH)/bin/wails build -tags webkit2_41 -ldflags "-X ${pkg}.Version=${TAG} -X ${pkg}.Commit=${commit}" + - name: Protocol freshness + run: make check-protocol + + - name: Check Wails bindings + run: make check-wails-bindings + - name: Check formatting run: | gofmt -l . | tee /tmp/gofmt-out diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d82e936c..284a74c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,6 +36,9 @@ jobs: - name: Build Wails app run: $(go env GOPATH)/bin/wails build -tags webkit2_41 + - name: Check Wails bindings + run: make check-wails-bindings + - name: Vet run: go vet -tags webkit2_41 ./... diff --git a/Makefile b/Makefile index 973a2e5f..bbafdfb3 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ VERSION_PKG := github.com/MMinasyan/lightcode/internal/version LDFLAGS := -X $(VERSION_PKG).Version=$(VERSION) -X $(VERSION_PKG).Commit=$(COMMIT) build: - ~/go/bin/wails build -ldflags '$(LDFLAGS)' + ~/go/bin/wails build -tags webkit2_41 -ldflags '$(LDFLAGS)' test: go test ./... @@ -58,10 +58,17 @@ uninstall: generate-protocol: go tool oapi-codegen --config protocol/oapi-codegen.yaml -o protocol/protocol.gen.go protocol/openapi.yaml + go run ./internal/protocolgen -schema protocol/openapi.yaml -out internal/clientops/operations.gen.go -native-contract-out frontend/src/generated/native-invocations.json cd frontend && npm run generate-protocol check-protocol: generate-protocol - sh scripts/check-protocol-generated.sh + sh scripts/check-generated-files.sh protocol/protocol.gen.go frontend/src/generated/protocol internal/clientops/operations.gen.go frontend/src/generated/native-invocations.json cd frontend && npm run typecheck:protocol -.PHONY: build test test-race test-integration bench fuzz-short install-lint-tools lint install uninstall generate-protocol check-protocol +# Compares the Wails-generated Go bindings against the index. Prerequisite: +# the real Wails build (which runs the models.ts normalizer) has produced the +# tracked files under frontend/client-bindings/wailsjs/go. +check-wails-bindings: + sh scripts/check-generated-files.sh frontend/client-bindings/wailsjs/go + +.PHONY: build test test-race test-integration bench fuzz-short install-lint-tools lint install uninstall generate-protocol check-protocol check-wails-bindings diff --git a/agent/public_isolation_test.go b/agent/public_isolation_test.go index 5fdc5952..b6b3b2a7 100644 --- a/agent/public_isolation_test.go +++ b/agent/public_isolation_test.go @@ -29,6 +29,11 @@ const tiktokenPkg = "github.com/pkoukk/tiktoken-go" // and client. const oapiRuntimePkg = "github.com/oapi-codegen/runtime" +// kinOpenAPIPkg is the one third-party dependency the private protocol build +// command permits: the pinned schema loader that derives the native operation +// bindings and their invocation contract from the committed schema. +const kinOpenAPIPkg = "github.com/getkin/kin-openapi/openapi3" + // retiredPackageRoots is the one uniform rejection rule for the retired legacy // package roots selected by this modernization: no tracked production file may // live under one of these internal package trees or import one. The list is not @@ -296,6 +301,10 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str if imp != clientPkg && imp != protocolPkg && !std[imp] { problems = append(problems, rel+": internal/clientops imports "+imp+"; the shared typed invocation helper may import only the standard library, "+clientPkg+", and "+protocolPkg) } + case "internal/protocolgen": + if imp != kinOpenAPIPkg && !std[imp] { + problems = append(problems, rel+": internal/protocolgen imports "+imp+"; the private protocol build command may import only the standard library and "+kinOpenAPIPkg) + } case "internal/acpclient": if imp != clientPkg && imp != protocolPkg && imp != clienthostPkg && imp != clientopsPkg && imp != versionPkg && !std[imp] { problems = append(problems, rel+": internal/acpclient imports "+imp+"; the target ACP bridge may import only the standard library, "+clientPkg+", "+protocolPkg+", "+clienthostPkg+", "+clientopsPkg+", and the native version helper") @@ -326,7 +335,7 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str // TestDependencyRulesRejectNonStdlibDotlessImports proves the allowlists use authoritative standard-library membership, not a dot-in-path shape: the cgo pseudo-import "C" and a dotless path outside the stdlib set fail every package row, while ordinary stdlib imports, the model dependency on agent's side, and internal/storage's dependency on harness still pass. The foundation's own reverse and skipping edges — model or agent reaching harness, any target package reaching internal/storage, and the driver reaching a target package — are each rejected exactly once. func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { std := standardLibraryImports(t) - for _, rel := range []string{"model/x.go", "agent/x.go", "harness/x.go", "internal/storage/x.go", "internal/toolargs/x.go", "protocol/x.go", "client/x.go", "internal/terminal/x.go", "internal/hostprocess/x.go", "internal/clienthost/x.go", "internal/clientview/x.go", "internal/clientops/x.go", "internal/acpclient/x.go", "internal/cliclient/x.go", "internal/desktopclient/x.go"} { + for _, rel := range []string{"model/x.go", "agent/x.go", "harness/x.go", "internal/storage/x.go", "internal/toolargs/x.go", "protocol/x.go", "client/x.go", "internal/terminal/x.go", "internal/hostprocess/x.go", "internal/clienthost/x.go", "internal/clientview/x.go", "internal/clientops/x.go", "internal/protocolgen/x.go", "internal/acpclient/x.go", "internal/cliclient/x.go", "internal/desktopclient/x.go"} { for _, imp := range []string{"C", "notreal/pkg"} { if problems := checkTrackedGoFile(rel, []string{imp}, std); len(problems) != 1 { t.Errorf("%s importing %q: %d problems, want 1: %v", rel, imp, len(problems), problems) @@ -769,6 +778,36 @@ func TestDependencyRulesClientViewIsolation(t *testing.T) { } } +// TestDependencyRulesProtocolGenIsolation enforces the private protocol build +// command's exact allowlist: the standard library and the pinned schema loader +// only. The foundation, the Runtime, the target clients and the generated +// contracts are its nearest forbidden siblings, and another subpackage of the +// same loader module is not an allowed edge either. +func TestDependencyRulesProtocolGenIsolation(t *testing.T) { + std := standardLibraryImports(t) + if problems := checkTrackedGoFile("internal/protocolgen/x.go", []string{ + "context", "encoding/json", "flag", "fmt", "go/format", "os", "sort", "strconv", "strings", "text/template", + kinOpenAPIPkg, + }, std); len(problems) != 0 { + t.Errorf("allowed internal/protocolgen imports flagged: %v", problems) + } + for _, imp := range []string{ + publicModule + "/model", publicModule + "/agent", publicModule + "/harness", + publicModule + "/runtime", publicModule + "/internal/agent", publicModule + "/internal/config", + publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/plugins", + publicModule + "/internal/terminal", publicModule + "/internal/cli", + publicModule + "/internal/clienthost", publicModule + "/internal/clientview", + publicModule + "/internal/clientops", publicModule + "/internal/desktopclient", + publicModule + "/internal/acpclient", publicModule + "/internal/cliclient", + publicModule + "/client", publicModule + "/protocol", + oapiRuntimePkg, tiktokenPkg, "github.com/getkin/kin-openapi/openapi3filter", + } { + if problems := checkTrackedGoFile("internal/protocolgen/x.go", []string{imp}, std); len(problems) != 1 { + t.Errorf("internal/protocolgen importing %q: %d problems, want 1: %v", imp, len(problems), problems) + } + } +} + // TestDependencyRulesClientOpsIsolation enforces the shared typed invocation // helper's exact allowlist. The reverse edge is covered by the directory // matrix. diff --git a/client/client.go b/client/client.go index e4327584..4e170537 100644 --- a/client/client.go +++ b/client/client.go @@ -102,10 +102,10 @@ func Connect(ctx context.Context, discoveryPath string) (*Client, error) { if health.JSON200.ProtocolVersion == "" { return nil, fmt.Errorf("authenticated health carries no protocol version: %w", ErrNoRuntime) } - if live := health.JSON200; live.ProtocolVersion != protocol.N1 { + if live := health.JSON200; live.ProtocolVersion != protocol.N2 { return nil, fmt.Errorf("live protocol version %q: %w", live.ProtocolVersion, ErrIncompatibleProtocol) } - if record.ProtocolVersion != string(protocol.N1) { + if record.ProtocolVersion != string(protocol.N2) { return nil, fmt.Errorf("discovery protocol version %q disagrees with the authenticated live owner: %w", record.ProtocolVersion, ErrNoRuntime) } return &Client{ClientWithResponses: transport, instance: record.InstanceId}, nil diff --git a/client/client_test.go b/client/client_test.go index 4f97b16f..8cfc934e 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -26,6 +26,9 @@ import ( const ( testInstance = "0123456789abcdef0123456789abcdef" testCredential = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + // testVersion is the current wire identity every compatible fixture + // speaks; the incompatible rows keep the literal old "1". + testVersion = string(protocol.N2) ) // recordContent builds one private four-field discovery document. @@ -227,8 +230,8 @@ func writeFrames(frames string) http.HandlerFunc { // mode-0600 single document with the exact four fields and a loopback HTTP // endpoint; every deviation is one ErrNoRuntime. func TestConnectStrictDiscovery(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "1"), nil) - valid := recordContent(server.URL, testInstance, "1", testCredential) + server := newFixture(t, jsonHealth(testInstance, testVersion), nil) + valid := recordContent(server.URL, testInstance, testVersion, testCredential) t.Run("valid record connects", func(t *testing.T) { c := connectFixture(t, writeDiscoveryContent(t, valid)) @@ -267,22 +270,22 @@ func TestConnectStrictDiscovery(t *testing.T) { {"unknown member", strings.TrimSuffix(valid, "}") + `,"extra":1}`}, {"trailing document", valid + valid}, {"null document", "null"}, - {"missing member", fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"1"}`, server.URL, testInstance)}, - {"wrong member type", fmt.Sprintf(`{"endpoint":1,"instance_id":%q,"protocol_version":"1","credential":%q}`, testInstance, testCredential)}, - {"endpoint with userinfo", recordContent("http://user@"+strings.TrimPrefix(server.URL, "http://"), testInstance, "1", testCredential)}, - {"endpoint with path", recordContent(server.URL+"/", testInstance, "1", testCredential)}, - {"endpoint with query", recordContent(server.URL+"?x=1", testInstance, "1", testCredential)}, - {"endpoint with fragment", recordContent(server.URL+"#frag", testInstance, "1", testCredential)}, - {"endpoint is https", recordContent("https://"+strings.TrimPrefix(server.URL, "http://"), testInstance, "1", testCredential)}, - {"endpoint host is localhost", recordContent("http://localhost:"+strings.TrimPrefix(server.URL, "http://127.0.0.1:"), testInstance, "1", testCredential)}, - {"endpoint has no port", recordContent("http://127.0.0.1", testInstance, "1", testCredential)}, - {"endpoint port is not numeric", recordContent("http://127.0.0.1:notaport", testInstance, "1", testCredential)}, - {"endpoint port is zero", recordContent("http://127.0.0.1:0", testInstance, "1", testCredential)}, - {"endpoint port overflows", recordContent("http://127.0.0.1:99999999999999999999", testInstance, "1", testCredential)}, - {"instance is uppercase hex", recordContent(server.URL, strings.ToUpper(testInstance), "1", testCredential)}, - {"instance is short", recordContent(server.URL, testInstance[:31], "1", testCredential)}, - {"credential is short", recordContent(server.URL, testInstance, "1", testCredential[:63])}, - {"credential is uppercase hex", recordContent(server.URL, testInstance, "1", strings.ToUpper(testCredential))}, + {"missing member", fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":%q}`, server.URL, testInstance, testVersion)}, + {"wrong member type", fmt.Sprintf(`{"endpoint":1,"instance_id":%q,"protocol_version":%q,"credential":%q}`, testInstance, testVersion, testCredential)}, + {"endpoint with userinfo", recordContent("http://user@"+strings.TrimPrefix(server.URL, "http://"), testInstance, testVersion, testCredential)}, + {"endpoint with path", recordContent(server.URL+"/", testInstance, testVersion, testCredential)}, + {"endpoint with query", recordContent(server.URL+"?x=1", testInstance, testVersion, testCredential)}, + {"endpoint with fragment", recordContent(server.URL+"#frag", testInstance, testVersion, testCredential)}, + {"endpoint is https", recordContent("https://"+strings.TrimPrefix(server.URL, "http://"), testInstance, testVersion, testCredential)}, + {"endpoint host is localhost", recordContent("http://localhost:"+strings.TrimPrefix(server.URL, "http://127.0.0.1:"), testInstance, testVersion, testCredential)}, + {"endpoint has no port", recordContent("http://127.0.0.1", testInstance, testVersion, testCredential)}, + {"endpoint port is not numeric", recordContent("http://127.0.0.1:notaport", testInstance, testVersion, testCredential)}, + {"endpoint port is zero", recordContent("http://127.0.0.1:0", testInstance, testVersion, testCredential)}, + {"endpoint port overflows", recordContent("http://127.0.0.1:99999999999999999999", testInstance, testVersion, testCredential)}, + {"instance is uppercase hex", recordContent(server.URL, strings.ToUpper(testInstance), testVersion, testCredential)}, + {"instance is short", recordContent(server.URL, testInstance[:31], testVersion, testCredential)}, + {"credential is short", recordContent(server.URL, testInstance, testVersion, testCredential[:63])}, + {"credential is uppercase hex", recordContent(server.URL, testInstance, testVersion, strings.ToUpper(testCredential))}, {"version is empty", recordContent(server.URL, testInstance, "", testCredential)}, } for _, tc := range cases { @@ -301,8 +304,8 @@ func TestConnectHealthClassification(t *testing.T) { other := strings.Repeat("a", 32) t.Run("health instance differs", func(t *testing.T) { - server := newFixture(t, jsonHealth(other, "1"), nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + server := newFixture(t, jsonHealth(other, testVersion), nil) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) assertNoRuntime(t, err) }) @@ -321,27 +324,27 @@ func TestConnectHealthClassification(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, tc.body) }, nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) assertNoRuntime(t, err) }) } }) t.Run("live version differs", func(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "2"), nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + server := newFixture(t, jsonHealth(testInstance, "1"), nil) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) assertIncompatible(t, err) }) t.Run("record and live versions differ", func(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "2"), nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "2", testCredential)) + server := newFixture(t, jsonHealth(testInstance, "1"), nil) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) assertIncompatible(t, err) }) t.Run("record version disagrees with a live compatible owner", func(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "1"), nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "2", testCredential)) + server := newFixture(t, jsonHealth(testInstance, testVersion), nil) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) assertNoRuntime(t, err) }) @@ -349,7 +352,7 @@ func TestConnectHealthClassification(t *testing.T) { server := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusInternalServerError) }, nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) assertNoRuntime(t, err) }) @@ -358,7 +361,7 @@ func TestConnectHealthClassification(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"instance_id":`) }, nil) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) assertNoRuntime(t, err) }) @@ -371,11 +374,11 @@ func TestConnectHealthClassification(t *testing.T) { mux.HandleFunc("/elsewhere", func(w http.ResponseWriter, _ *http.Request) { redirected = true w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"1"}`, testInstance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":%q}`, testInstance, testVersion) }) server := httptest.NewServer(mux) t.Cleanup(server.Close) - _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + _, err := client.Connect(context.Background(), writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) assertNoRuntime(t, err) if redirected { t.Fatal("the readiness probe followed the redirect") @@ -388,7 +391,7 @@ func TestConnectHealthClassification(t *testing.T) { close(arrived) <-r.Context().Done() }, nil) - path := writeDiscovery(t, server.URL, testInstance, "1", testCredential) + path := writeDiscovery(t, server.URL, testInstance, testVersion, testCredential) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { @@ -414,8 +417,8 @@ func TestEventsFrames(t *testing.T) { ": keepalive\n\n" + notificationFrame(t, sessionChangedEvent(t, testInstance, "3", "0")) + notificationFrame(t, sessionChangedEvent(t, testInstance, "2", "0")) - server := newFixture(t, jsonHealth(testInstance, "1"), writeFrames(frames)) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + server := newFixture(t, jsonHealth(testInstance, testVersion), writeFrames(frames)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) events, errs := c.Events(context.Background()) delivered, err := drainEvents(events, errs) if !errors.Is(err, client.ErrResyncRequired) { @@ -468,8 +471,8 @@ func TestEventsFrames(t *testing.T) { t.Fatalf("build tool_finished: %v", err) } frames := notificationFrame(t, delta) + notificationFrame(t, refusal) + notificationFrame(t, started) + notificationFrame(t, finished) - server := newFixture(t, jsonHealth(testInstance, "1"), writeFrames(frames)) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + server := newFixture(t, jsonHealth(testInstance, testVersion), writeFrames(frames)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) events, errs := c.Events(context.Background()) delivered, err := drainEvents(events, errs) if !errors.Is(err, client.ErrResyncRequired) { @@ -504,8 +507,8 @@ func TestEventsFrames(t *testing.T) { frames := notificationFrame(t, scopeOpenedEvent(t, workspaceEventScope())) + notificationFrame(t, scopeOpenedEvent(t, agentEventScope())) + notificationFrame(t, job) - server := newFixture(t, jsonHealth(testInstance, "1"), writeFrames(frames)) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + server := newFixture(t, jsonHealth(testInstance, testVersion), writeFrames(frames)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) events, errs := c.Events(context.Background()) delivered, err := drainEvents(events, errs) if !errors.Is(err, client.ErrResyncRequired) { @@ -557,8 +560,8 @@ func TestEventsFrames(t *testing.T) { } for _, tc := range malformed { t.Run(tc.name, func(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "1"), writeFrames(tc.frame)) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + server := newFixture(t, jsonHealth(testInstance, testVersion), writeFrames(tc.frame)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) events, errs := c.Events(context.Background()) delivered, err := drainEvents(events, errs) if !errors.Is(err, client.ErrResyncRequired) { @@ -571,10 +574,10 @@ func TestEventsFrames(t *testing.T) { } t.Run("status failure", func(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "1"), func(w http.ResponseWriter, _ *http.Request) { + server := newFixture(t, jsonHealth(testInstance, testVersion), func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusInternalServerError) }) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) events, errs := c.Events(context.Background()) delivered, err := drainEvents(events, errs) if !errors.Is(err, client.ErrResyncRequired) { @@ -587,7 +590,7 @@ func TestEventsFrames(t *testing.T) { t.Run("queue overflow", func(t *testing.T) { const total = 300 - server := newFixture(t, jsonHealth(testInstance, "1"), func(w http.ResponseWriter, r *http.Request) { + server := newFixture(t, jsonHealth(testInstance, testVersion), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) flusher, _ := w.(http.Flusher) @@ -603,7 +606,7 @@ func TestEventsFrames(t *testing.T) { } } }) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) events, errs := c.Events(context.Background()) // The caller never reads, so the bounded queue overflows on frame 257 // and the stream ends with the resync signal alone. @@ -617,7 +620,7 @@ func TestEventsFrames(t *testing.T) { }) t.Run("caller cancellation", func(t *testing.T) { - server := newFixture(t, jsonHealth(testInstance, "1"), func(w http.ResponseWriter, r *http.Request) { + server := newFixture(t, jsonHealth(testInstance, testVersion), func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) if flusher, ok := w.(http.Flusher); ok { @@ -625,7 +628,7 @@ func TestEventsFrames(t *testing.T) { } <-r.Context().Done() }) - c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, "1", testCredential)) + c := connectFixture(t, writeDiscovery(t, server.URL, testInstance, testVersion, testCredential)) ctx, cancel := context.WithCancel(context.Background()) events, errs := c.Events(ctx) cancel() diff --git a/frontend/protocol-client.test.js b/frontend/protocol-client.test.js index 3ac270a7..7ba7a6ce 100644 --- a/frontend/protocol-client.test.js +++ b/frontend/protocol-client.test.js @@ -22,7 +22,7 @@ test('configured bearer credential reaches health and events', async () => { headers: { 'content-type': 'text/event-stream' }, }); } - return Response.json({ instance_id: instanceID, protocol_version: '1' }); + return Response.json({ instance_id: instanceID, protocol_version: '2' }); }); const client = createClient({ baseUrl: 'http://127.0.0.1:1', @@ -31,7 +31,7 @@ test('configured bearer credential reaches health and events', async () => { }); const health = await getHealth({ client }); - expect(health.data).toEqual({ instance_id: instanceID, protocol_version: '1' }); + expect(health.data).toEqual({ instance_id: instanceID, protocol_version: '2' }); const events = await getEvents({ client, sseMaxRetryAttempts: 1 }); const received = []; @@ -49,7 +49,7 @@ test('configured bearer credential reaches health and events', async () => { }); test('absent auth does not invent a bearer header', async () => { - const fetchFn = vi.fn(async () => Response.json({ instance_id: instanceID, protocol_version: '1' })); + const fetchFn = vi.fn(async () => Response.json({ instance_id: instanceID, protocol_version: '2' })); const client = createClient({ baseUrl: 'http://127.0.0.1:1', fetch: fetchFn }); await getHealth({ client }); diff --git a/frontend/protocol-mounted.test.js b/frontend/protocol-mounted.test.js index 35ddbd75..c9aafc81 100644 --- a/frontend/protocol-mounted.test.js +++ b/frontend/protocol-mounted.test.js @@ -42,7 +42,7 @@ mounted('generated SDK round-trips special identity query values over the mounte // 0600 mode; these are the record-shape checks it does not duplicate. expect(discovery.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(discovery.instance_id).toMatch(/^[0-9a-f]{32}$/); - expect(discovery.protocol_version).toBe('1'); + expect(discovery.protocol_version).toBe('2'); // The credential is validated without an assertion diff: a mismatch must // never print the value this check exists to protect. if (typeof discovery.credential !== 'string' || !/^[0-9a-f]{64}$/.test(discovery.credential)) { @@ -52,7 +52,7 @@ mounted('generated SDK round-trips special identity query values over the mounte const client = createClient({ baseUrl: discovery.endpoint, auth: discovery.credential }); const health = await getHealth({ client }); expect(health.error).toBeUndefined(); - expect(health.data).toEqual({ instance_id: discovery.instance_id, protocol_version: '1' }); + expect(health.data).toEqual({ instance_id: discovery.instance_id, protocol_version: '2' }); // The open warning source crosses the generated TypeScript boundary: the // mounted owner's real Runtime-scoped reporter plugin attributes its report diff --git a/frontend/src/client/ProviderEditor.svelte b/frontend/src/client/ProviderEditor.svelte index fe72b5bd..467d475d 100644 --- a/frontend/src/client/ProviderEditor.svelte +++ b/frontend/src/client/ProviderEditor.svelte @@ -17,10 +17,10 @@ // schema requires; the shared field block renders one row per descriptor. const fieldsOf = (kind, ...members) => members.map((member) => [kind, member]); const PROVIDER_FIELDS = [ - ...fieldsOf('text', 'name', 'base_url', 'api_key_env', 'max_tokens_field'), + ...fieldsOf('text', 'name', 'base_url', 'api_key_env'), ['role', 'system_role'], ...fieldsOf('bool', 'usage_in_stream', 'discovery', 'hidden'), - ...fieldsOf('json', 'headers', 'options', 'extra_body', 'protocol_metadata'), + ...fieldsOf('json', 'headers', 'extra_body', 'protocol_metadata'), ]; const MODEL_FIELDS = [ ['text', 'name'], diff --git a/frontend/src/client/app.test.ts b/frontend/src/client/app.test.ts index ff6c99f3..a4e0f69c 100644 --- a/frontend/src/client/app.test.ts +++ b/frontend/src/client/app.test.ts @@ -28,6 +28,7 @@ import { AppVersion, Call, PickDirectory, SetTitle } from '../../client-bindings import { BrowserOpenURL } from '../../client-bindings/wailsjs/runtime/runtime'; import { SessionReader } from './reader'; import { AttemptLedger } from './attempts'; +import { assertNativeInvocations } from './nativecontract'; const INSTANCE = '0123456789abcdef0123456789abcdef'; @@ -264,17 +265,30 @@ async function settle() { } afterEach(async () => { + // The mounted UI's recorded native invocations are asserted here, outside + // every application promise and catch, before clearAllMocks removes them: a + // violating envelope fails the teardown even when the application's own + // expected outcome still rendered. The DOM and mock cleanup stays in the + // finally block so an assertion failure cannot contaminate the next test. + const mountedUI = mounted.length > 0; for (const component of mounted.splice(0)) { unmount(component); } - document.body.innerHTML = ''; - vi.clearAllMocks(); - // clearAllMocks does not reset implementations; a previous test's - // mockImplementation can leak into the next test. Reset the two - // highest-traffic mocks to a neutral default so every test that needs - // specific behavior sets its own implementation explicitly. - vi.mocked(sdk.ListSessions).mockImplementation(async () => ok([])); - vi.mocked(sdk.GetSessionHydration).mockImplementation(async () => ok(makeHydration())); + await tick(); + try { + if (mountedUI) { + assertNativeInvocations(vi.mocked(Call).mock.calls); + } + } finally { + document.body.innerHTML = ''; + vi.clearAllMocks(); + // clearAllMocks does not reset implementations; a previous test's + // mockImplementation can leak into the next test. Reset the two + // highest-traffic mocks to a neutral default so every test that needs + // specific behavior sets its own implementation explicitly. + vi.mocked(sdk.ListSessions).mockImplementation(async () => ok([])); + vi.mocked(sdk.GetSessionHydration).mockImplementation(async () => ok(makeHydration())); + } }); describe('initial composition', () => { @@ -1590,7 +1604,6 @@ function providerFixture(overrides: Record = {}) { user_headers: {}, system_role: 'system', usage_in_stream: false, - max_tokens_field: 'max_tokens', discovery: true, hidden: false, models: [modelFixture()], @@ -2818,7 +2831,7 @@ describe('provider and model tasks', () => { await settle(); (target.querySelector('[aria-label="Select provider prov1"]') as HTMLElement).click(); await settle(); - await changeInput(target, 'provider options', '{not json'); + await changeInput(target, 'provider extra_body', '{not json'); await settle(); await settle(); // The malformed composed invocation text is a known local rejection: no @@ -2826,7 +2839,29 @@ describe('provider and model tasks', () => { expect(vi.mocked(sdk.UpdateProviderDetail)).not.toHaveBeenCalled(); expect(target.textContent).toContain('Provider edit failed'); expect(target.textContent).not.toContain('Outcome unknown'); - expect((target.querySelector('[aria-label="provider options"]') as HTMLTextAreaElement).value).toBe('{not json'); + expect((target.querySelector('[aria-label="provider extra_body"]') as HTMLTextAreaElement).value).toBe('{not json'); + }); + + it('renders no retired provider controls beside the live members', async () => { + const connection = fakeConnection(); + basicMocks(connection); + vi.mocked(sdk.ListProviders).mockResolvedValue(ok({ configuration_revision: { instance_id: INSTANCE, generation: '1' }, providers: [providerFixture()] })); + vi.mocked(sdk.GetProviderDetail).mockResolvedValue(ok({ configuration_revision: { instance_id: INSTANCE, generation: '1' }, provider: providerFixture() })); + const { target } = await mountApp(connection); + await openSettings(target); + (target.querySelector('[aria-label="Providers"]') as HTMLElement).click(); + await settle(); + (target.querySelector('[aria-label="Select provider prov1"]') as HTMLElement).click(); + await settle(); + // The retired controls have no row and no reset affordance... + expect(target.querySelector('[aria-label="provider max_tokens_field"]')).toBeNull(); + expect(target.querySelector('[aria-label="Reset provider max_tokens_field"]')).toBeNull(); + expect(target.querySelector('[aria-label="provider options"]')).toBeNull(); + expect(target.querySelector('[aria-label="Reset provider options"]')).toBeNull(); + // ...while the live provider and model members stay mounted. + expect(target.querySelector('[aria-label="provider extra_body"]')).not.toBeNull(); + expect(target.querySelector('[aria-label="provider api_key_env"]')).not.toBeNull(); + expect(target.querySelector('[aria-label="m1 max_output_tokens"]')).not.toBeNull(); }); it('resets one provider field through the generated reset path', async () => { @@ -3137,7 +3172,7 @@ describe('provider exact reads', () => { const RAW_DETAIL = '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + '"provider":{"id":"prov1","name":"Prov One","builtin":false,"connected":true,"key_source":"managed",' + '"base_url":"http://127.0.0.1:1/v1","api_key_env":"PROV1_KEY","headers":{},"user_headers":{},' - + '"system_role":"system","usage_in_stream":false,"max_tokens_field":"max_tokens","discovery":true,"hidden":false,' + + '"system_role":"system","usage_in_stream":false,"discovery":true,"hidden":false,' + '"protocol_metadata":{"limit":9007199254740993,"rate":1e400},' + '"models":[{"id":"m1","name":"Model One","context_window":9007199254740993,"max_output_tokens":1e400,' + '"input_modalities":["text"],"system_role":"system","usage_in_stream":false,"hidden":false,"source":"user",' diff --git a/frontend/src/client/connection.test.ts b/frontend/src/client/connection.test.ts index 69925109..3b284da2 100644 --- a/frontend/src/client/connection.test.ts +++ b/frontend/src/client/connection.test.ts @@ -40,7 +40,7 @@ describe('RuntimeConnection.open', () => { vi.mocked(NextEvent).mockImplementation(() => new Promise(() => {})); vi.mocked(Call).mockResolvedValue({ status: 200, - body: '{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"1"}', + body: '{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"2"}', invalid: false, }); const connection = new RuntimeConnection(); diff --git a/frontend/src/client/nativecontract.test.ts b/frontend/src/client/nativecontract.test.ts new file mode 100644 index 00000000..b48d5089 --- /dev/null +++ b/frontend/src/client/nativecontract.test.ts @@ -0,0 +1,113 @@ +// The native invocation contract assertion's own rows: the allowed envelopes +// the mounted clients compose, and the nearest forbidden siblings — unknown or +// prototype-named operations, forbidden/missing/null/type members, undeclared +// query members and non-object query/body values — each rejected. +import { describe, expect, it } from 'vitest'; + +import { assertNativeInvocations } from './nativecontract'; + +function call(operation: string, params: string): readonly unknown[] { + return ['1', operation, params]; +} + +function allows(operation: string, params: string): void { + expect(() => assertNativeInvocations([call(operation, params)])).not.toThrow(); +} + +function rejects(operation: string, params: string): void { + expect(() => assertNativeInvocations([call(operation, params)])).toThrow(); +} + +describe('native invocation contract', () => { + it('accepts the envelopes the mounted clients actually compose', () => { + allows('GetHealth', ''); + allows('GetHealth', '{}'); + allows('GetConfiguration', '{}'); + allows('ListSessions', '{"query":{"workspace":"/ws","lifecycle":"open"}}'); + allows('ListModels', '{"query":{"all":false}}'); + allows('GetSessionHydration', '{"session_id":"root1"}'); + allows('GetSessionHistory', '{"session_id":"root1"}'); + allows('GetSessionHistory', '{"session_id":"root1","query":{}}'); + allows('GetSessionHistory', '{"session_id":"root1","query":{"cursor":"abc"}}'); + allows('SubmitSession', '{"session_id":"root1","body":{"operation_id":"op1"}}'); + allows('CreateSession', '{"body":{"workspace":"/ws","agent_type":"primary"}}'); + allows('ResetProviderField', '{"field":"name","query":{"provider_id":"prov1"}}'); + allows('ResetProviderModelField', '{"field":"hidden","query":{"provider_id":"prov1","model_id":"m1"}}'); + allows('UpdateProviderModel', '{"query":{"provider_id":"prov1","model_id":"m1"},"body":{"name":"M"}}'); + // Opaque bodies stay opaque: exact lexemes and unknown members inside a + // body are never re-interpreted here. + allows('UpdateProviderDetail', '{"query":{"provider_id":"prov1"},"body":{"extra_body":{"n":9007199254740993}}}'); + }); + + it('rejects an operation the contract does not declare, including prototype names', () => { + rejects('Nope', '{}'); + rejects('GetEvents', '{}'); + rejects('', '{}'); + rejects('toString', '{}'); + rejects('constructor', '{}'); + rejects('__proto__', '{}'); + rejects('hasOwnProperty', '{}'); + }); + + it('rejects forbidden, undeclared, missing, null and mistyped envelope members', () => { + rejects('GetHealth', '{"session_id":"root1"}'); + rejects('GetHealth', '{"bogus":1}'); + rejects('GetHealth', '{"toString":"root1"}'); + rejects('DeleteSession', '{}'); + rejects('DeleteSession', '{"session_id":null}'); + rejects('DeleteSession', '{"session_id":5}'); + rejects('DeleteSession', '{"session_id":{}}'); + rejects('ResetProviderField', '{"query":{"provider_id":"prov1"}}'); + rejects('ResetProviderField', '{"field":5,"query":{"provider_id":"prov1"}}'); + rejects('ResetProviderField', '{"field":"name"}'); + rejects('CreateSession', '{}'); + rejects('CreateSession', '{"body":null}'); + rejects('CreateSession', '{"body":[]}'); + rejects('CreateSession', '{"body":"x"}'); + rejects('CreateSession', '{"body":5}'); + rejects('SubmitSession', '{}'); + rejects('SubmitSession', '{"body":{"operation_id":"op1"}}'); + }); + + it('rejects malformed params text and non-object envelopes', () => { + rejects('GetHealth', '{'); + rejects('GetHealth', '[]'); + rejects('GetHealth', '"x"'); + rejects('GetHealth', 'null'); + rejects('GetHealth', '5'); + rejects('GetHealth', '{} {}'); + }); + + it('rejects undeclared, missing and mistyped query members', () => { + rejects('ListSessions', '{}'); + rejects('ListSessions', '{"query":null}'); + rejects('ListSessions', '{"query":[]}'); + rejects('ListSessions', '{"query":5}'); + rejects('ListSessions', '{"query":{"workspace":"/ws"}}'); + rejects('ListSessions', '{"query":{"workspace":null,"lifecycle":"open"}}'); + rejects('ListSessions', '{"query":{"workspace":5,"lifecycle":"open"}}'); + rejects('ListSessions', '{"query":{"workspace":"/ws","lifecycle":"open","bogus":1}}'); + rejects('ListSessions', '{"query":{"workspace":"/ws","lifecycle":"open","toString":"x"}}'); + rejects('ListModels', '{"query":{"all":"yes"}}'); + rejects('ListModels', '{"query":{"all":null}}'); + rejects('GetSessionHistory', '{"session_id":"root1","query":null}'); + rejects('GetSessionHistory', '{"session_id":"root1","query":[]}'); + rejects('GetSessionHistory', '{"session_id":"root1","query":{"cursor":null}}'); + rejects('GetSessionHistory', '{"session_id":"root1","query":{"cursor":5}}'); + rejects('GetSessionHistory', '{"session_id":"root1","query":{"bogus":1}}'); + // The envelope's session_id belongs only to the operations that declare a + // session path; the retained query's own session_id is a query member. + rejects('GetRetainedCodeSnapshots', '{"session_id":"root1","query":{"workspace":"/ws"}}'); + rejects('GetRetainedCodeSnapshots', '{"query":{"workspace":"/ws"}}'); + allows('GetRetainedCodeSnapshots', '{"query":{"workspace":"/ws","session_id":"0123456789abcdef0123456789abcdef"}}'); + }); + + it('checks every recorded call, so one violating envelope fails the whole history', () => { + expect(() => + assertNativeInvocations([ + call('GetHealth', '{}'), + call('SubmitSession', '{"body":{"operation_id":"op1"}}'), + ]), + ).toThrow(/session_id is required/); + }); +}); diff --git a/frontend/src/client/nativecontract.ts b/frontend/src/client/nativecontract.ts new file mode 100644 index 00000000..1cf5b975 --- /dev/null +++ b/frontend/src/client/nativecontract.ts @@ -0,0 +1,132 @@ +// The test-only native invocation contract assertion. It reads the recorded +// native Call arguments — the same {generation, operation, params} tuples the +// scripted host received — and checks each one against the schema-derived +// native invocation metadata. It is an assertion helper, not a runtime +// validator: the shipped client never imports it, it never rewrites or +// re-serializes the dispatched text, and enums and identities stay with the +// Runtime's own semantic validation. +import metadata from '../generated/native-invocations.json'; + +type Mode = 'forbidden' | 'optional' | 'required'; +type QueryPrimitive = 'string' | 'boolean'; + +interface QueryMember { + required: boolean; + type: QueryPrimitive; +} + +interface OperationContract { + session_id: Mode; + field: Mode; + query: Mode; + body: Mode; + query_members: Record; +} + +const contracts = metadata as Record; +const ENVELOPE_MEMBERS = ['session_id', 'field', 'query', 'body'] as const; + +// own reports one object's own-property membership: an inherited name such as +// toString, constructor or __proto__ can never impersonate a declared +// operation or satisfy a required member. +function own(target: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(target, key); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// assertNativeInvocations checks every recorded mounted native invocation +// against the schema-derived contract. A violation throws with the offending +// operation and member, so the owning test's teardown fails loudly instead of +// hiding behind an expected application outcome. +export function assertNativeInvocations(calls: ReadonlyArray): void { + for (const call of calls) { + const operation = call[1]; + if (typeof operation !== 'string' || !own(contracts, operation)) { + throw new Error(`native invocation ${String(operation)} is not a declared native operation`); + } + const contract = contracts[operation]; + const envelope = envelopeOf(operation, call[2]); + for (const name of Object.keys(envelope)) { + if (!(ENVELOPE_MEMBERS as readonly string[]).includes(name)) { + throw new Error(`native invocation ${operation}: envelope member ${name} is not declared`); + } + if (contract[name as (typeof ENVELOPE_MEMBERS)[number]] === 'forbidden') { + throw new Error(`native invocation ${operation}: envelope member ${name} is forbidden for this operation`); + } + } + for (const name of ENVELOPE_MEMBERS) { + if (!own(envelope, name)) { + if (contract[name] === 'required') { + throw new Error(`native invocation ${operation}: envelope member ${name} is required`); + } + continue; + } + const value = envelope[name]; + if (value === null) { + throw new Error(`native invocation ${operation}: envelope member ${name} must not be null`); + } + if (name === 'session_id' || name === 'field') { + if (typeof value !== 'string') { + throw new Error(`native invocation ${operation}: envelope member ${name} must be a string`); + } + continue; + } + if (name === 'query') { + checkQuery(operation, contract, value); + continue; + } + if (!isPlainObject(value)) { + throw new Error(`native invocation ${operation}: envelope member body must be one JSON object`); + } + } + } +} + +// envelopeOf decodes one recorded params text for inspection only; the text +// the host received stays untouched. The empty text is the empty envelope, +// exactly like the Go binder's missing-params case. +function envelopeOf(operation: string, params: unknown): Record { + if (typeof params !== 'string') { + throw new Error(`native invocation ${operation}: params text is not a string`); + } + if (params === '') { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(params); + } catch { + throw new Error(`native invocation ${operation}: params text is not JSON`); + } + if (!isPlainObject(parsed)) { + throw new Error(`native invocation ${operation}: envelope must be one JSON object`); + } + return parsed; +} + +// checkQuery checks one declared query object: every provided member must be +// declared with the matching primitive type and every declared required +// member must be present. +function checkQuery(operation: string, contract: OperationContract, value: unknown): void { + if (!isPlainObject(value)) { + throw new Error(`native invocation ${operation}: envelope member query must be one JSON object`); + } + const query = value; + for (const name of Object.keys(query)) { + if (!own(contract.query_members, name)) { + throw new Error(`native invocation ${operation}: query member ${name} is not declared`); + } + const member = contract.query_members[name]; + if (typeof query[name] !== member.type) { + throw new Error(`native invocation ${operation}: query member ${name} must be a ${member.type}`); + } + } + for (const name of Object.keys(contract.query_members)) { + if (contract.query_members[name].required && !own(query, name)) { + throw new Error(`native invocation ${operation}: query member ${name} is required`); + } + } +} diff --git a/frontend/src/client/stack.test.ts b/frontend/src/client/stack.test.ts index 4e24a8af..d594aefb 100644 --- a/frontend/src/client/stack.test.ts +++ b/frontend/src/client/stack.test.ts @@ -22,6 +22,7 @@ import type { desktopclient } from '../../client-bindings/wailsjs/go/models'; import { RuntimeConnection } from './connection'; import { extractSettingsText } from './exactjson'; import { invokeNative } from './results'; +import { assertNativeInvocations } from './nativecontract'; const INSTANCE = '0123456789abcdef0123456789abcdef'; const NEWER = '2026-10-02T10:00:00Z'; @@ -290,9 +291,24 @@ async function settle() { } afterEach(async () => { + // The mounted UI's recorded native invocations are asserted here, outside + // every application promise and catch, before clearAllMocks removes them: a + // violating envelope fails the teardown even when the application's own + // expected transport outcome still rendered. Tests that exercise only the + // generic invocation boundary without mounting UI acquire no such + // obligation. The DOM and mock cleanup stays in the finally block so an + // assertion failure cannot contaminate the next test. + const mountedUI = mounted.length > 0; for (const component of mounted.splice(0)) unmount(component); - document.body.innerHTML = ''; - vi.clearAllMocks(); + await tick(); + try { + if (mountedUI) { + assertNativeInvocations(vi.mocked(Call).mock.calls); + } + } finally { + document.body.innerHTML = ''; + vi.clearAllMocks(); + } }); describe('real generated stack over the byte bridge', () => { @@ -685,7 +701,7 @@ describe('Step 8 real generated captures over the byte bridge', () => { const rawDetail = '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + '"provider":{"id":"prov1","name":"Prov One","builtin":false,"connected":true,"key_source":"managed",' + '"base_url":"http://127.0.0.1:1/v1","api_key_env":"PROV1_KEY","headers":{},"user_headers":{},' - + '"system_role":"system","usage_in_stream":false,"max_tokens_field":"max_tokens","discovery":true,"hidden":false,' + + '"system_role":"system","usage_in_stream":false,"discovery":true,"hidden":false,' + '"protocol_metadata":{"limit":9007199254740993,"rate":1e400},' + '"models":[{"id":"m1","name":"Model One","context_window":9007199254740993,"max_output_tokens":1e400,' + '"input_modalities":["text"],"system_role":"system","usage_in_stream":false,"hidden":false,"source":"user",' @@ -768,7 +784,7 @@ describe('the shared invocation boundary', () => { it('passes well-formed opaque overflow lexemes through the syntax check unchanged', async () => { vi.mocked(Call).mockResolvedValue(json(200, {})); - const params = '{"body":{"extra_body":{"n":9007199254740993},"options":{"big":1e400}}}'; + const params = '{"body":{"extra_body":{"n":9007199254740993,"big":1e400}}}'; const result = await invokeNative(() => handle, 'edit', 'UpdateProviderDetail', params, JSON.parse); expect(result.kind).toBe('ok'); expect(Call).toHaveBeenCalledExactlyOnceWith('1', 'UpdateProviderDetail', params); diff --git a/frontend/src/generated/native-invocations.json b/frontend/src/generated/native-invocations.json new file mode 100644 index 00000000..5cf0562e --- /dev/null +++ b/frontend/src/generated/native-invocations.json @@ -0,0 +1,382 @@ +{ + "ArchiveSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "CompactSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "ConnectProvider": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "required", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "CreateProvider": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "CreateSession": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "DeleteProviderDetail": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "DeleteProviderModel": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "model_id": { + "required": true, + "type": "string" + }, + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "DeleteSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "DisconnectProvider": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "DiscoverProviderCandidates": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "DiscoverProviderModelCandidates": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "ForkSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "GetConfiguration": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "GetHealth": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "GetProviderDetail": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "GetRetainedCodeSnapshots": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "session_id": { + "required": true, + "type": "string" + }, + "workspace": { + "required": true, + "type": "string" + } + } + }, + "GetSessionCodeSnapshots": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "GetSessionHistory": { + "session_id": "required", + "field": "forbidden", + "query": "optional", + "body": "forbidden", + "query_members": { + "cursor": { + "required": false, + "type": "string" + } + } + }, + "GetSessionHydration": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "GetWarnings": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "InterruptSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "ListModels": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "all": { + "required": true, + "type": "boolean" + } + } + }, + "ListProviderModels": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "ListProviders": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "ListSessions": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden", + "query_members": { + "lifecycle": { + "required": true, + "type": "string" + }, + "workspace": { + "required": true, + "type": "string" + } + } + }, + "ListWorkspaces": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "ReadWorkspaceFile": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "ReloadConfiguration": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "ReopenSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "ResetProviderField": { + "session_id": "forbidden", + "field": "required", + "query": "required", + "body": "forbidden", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "ResetProviderModelField": { + "session_id": "forbidden", + "field": "required", + "query": "required", + "body": "forbidden", + "query_members": { + "model_id": { + "required": true, + "type": "string" + }, + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "RevertRetainedCode": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "RevertSessionCode": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "SetAgentTypeModel": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "required", + "query_members": { + "agent_type": { + "required": true, + "type": "string" + } + } + }, + "SetSessionAgentType": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "StopSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "forbidden", + "query_members": {} + }, + "SubmitSession": { + "session_id": "required", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "UpdateConfigurationSettings": { + "session_id": "forbidden", + "field": "forbidden", + "query": "forbidden", + "body": "required", + "query_members": {} + }, + "UpdateProviderDetail": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "required", + "query_members": { + "provider_id": { + "required": true, + "type": "string" + } + } + }, + "UpdateProviderModel": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "required", + "query_members": { + "model_id": { + "required": true, + "type": "string" + }, + "provider_id": { + "required": true, + "type": "string" + } + } + } +} diff --git a/frontend/src/generated/protocol/types.gen.ts b/frontend/src/generated/protocol/types.gen.ts index 671c4574..b5aaebc4 100644 --- a/frontend/src/generated/protocol/types.gen.ts +++ b/frontend/src/generated/protocol/types.gen.ts @@ -46,7 +46,7 @@ export type WarningsRevision = { export type Health = { instance_id: string; - protocol_version: '1'; + protocol_version: '2'; }; /** @@ -505,7 +505,7 @@ export type ProtocolMetadata = { * The one provider field vocabulary shared by the edit patch members and the field-reset path: every editable user-layer provider member. * */ -export type ProviderField = 'name' | 'base_url' | 'api_key_env' | 'headers' | 'options' | 'system_role' | 'usage_in_stream' | 'max_tokens_field' | 'extra_body' | 'discovery' | 'protocol_metadata' | 'hidden'; +export type ProviderField = 'name' | 'base_url' | 'api_key_env' | 'headers' | 'system_role' | 'usage_in_stream' | 'extra_body' | 'discovery' | 'protocol_metadata' | 'hidden'; /** * The one model field vocabulary shared by the edit patch members and the field-reset path: every editable user-layer model member. @@ -520,12 +520,8 @@ export type ProviderEdit = { headers?: { [key: string]: string; }; - options?: { - [key: string]: unknown; - }; system_role?: SystemRole; usage_in_stream?: boolean; - max_tokens_field?: string; extra_body?: { [key: string]: unknown; }; @@ -583,12 +579,8 @@ export type Provider = { user_headers: { [key: string]: string; }; - options?: { - [key: string]: unknown; - }; system_role: SystemRole; usage_in_stream: boolean; - max_tokens_field: string; extra_body?: { [key: string]: unknown; }; diff --git a/go.mod b/go.mod index 92ec73a8..378a23ff 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -57,7 +58,6 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/tools v0.48.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/internal/acpclient/repair_test.go b/internal/acpclient/repair_test.go index df8416ca..f8580901 100644 --- a/internal/acpclient/repair_test.go +++ b/internal/acpclient/repair_test.go @@ -86,7 +86,7 @@ func newFrameFixture(t *testing.T, instance string, parkHydration bool) *frameFi } } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"1"}`, f.instance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"2"}`, f.instance) }) mux.HandleFunc("/v1/events", f.serveEvents) mux.HandleFunc("/v1/sessions", func(w http.ResponseWriter, r *http.Request) { @@ -266,7 +266,7 @@ func (f *frameFixture) marker(open int) chan string { // writeRecord publishes this fixture's live discovery record. func (f *frameFixture) writeRecord(t *testing.T, dataDir string) { t.Helper() - record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"1","credential":%q}`, + record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"2","credential":%q}`, f.server.URL, f.instance, frameCredential) if err := os.WriteFile(filepath.Join(dataDir, "runtime.json"), []byte(record), 0o600); err != nil { t.Fatalf("write discovery record: %v", err) diff --git a/internal/acpclient/runner_test.go b/internal/acpclient/runner_test.go index 5db3fd40..7f30fead 100644 --- a/internal/acpclient/runner_test.go +++ b/internal/acpclient/runner_test.go @@ -54,7 +54,7 @@ func newFixture(t *testing.T, routes map[string]http.HandlerFunc) *fixture { f := &fixture{handlers: map[string]http.HandlerFunc{}, eventsOpen: make(chan struct{}, 16)} f.handlers["/v1/health"] = func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"1"}`, testInstance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"2"}`, testInstance) } f.handlers["/v1/events"] = f.idleEvents // Startup composition defaults: an empty open-root list and one created @@ -267,7 +267,7 @@ func startBridge(t *testing.T, f *fixture, workspace string) *bridge { func startBridgeWith(t *testing.T, f *fixture, workspace string, writer io.Writer) *bridge { t.Helper() dataDir := t.TempDir() - record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"1","credential":%q}`, + record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"2","credential":%q}`, f.server.URL, testInstance, testCredential) if err := os.WriteFile(filepath.Join(dataDir, "runtime.json"), []byte(record), 0o600); err != nil { t.Fatalf("write discovery record: %v", err) @@ -500,7 +500,7 @@ func sseFrame(document string) string { } func healthBody() string { - return fmt.Sprintf(`{"instance_id":%q,"protocol_version":"1"}`, testInstance) + return fmt.Sprintf(`{"instance_id":%q,"protocol_version":"2"}`, testInstance) } func hydrationBody(sessionID, workspace string) string { @@ -1026,7 +1026,7 @@ func TestACPEventsAndRepair(t *testing.T) { if err := json.Unmarshal(connected["params"], &health); err != nil { t.Fatalf("runtime/connected params: %v", err) } - if health["instance_id"] != testInstance || health["protocol_version"] != "1" { + if health["instance_id"] != testInstance || health["protocol_version"] != "2" { t.Fatalf("runtime/connected params = %v, want the generated Health", health) } resync := b.awaitFrame(t, methodFrame("runtime/resync_required")) diff --git a/internal/catalog/build.go b/internal/catalog/build.go index 61c0c968..c6881cbe 100644 --- a/internal/catalog/build.go +++ b/internal/catalog/build.go @@ -220,9 +220,6 @@ func applyProviderDefaults(providerID string, raw map[string]any) { if _, ok := raw["usage_in_stream"]; !ok { raw["usage_in_stream"] = true } - if _, ok := raw["max_tokens_field"]; !ok { - raw["max_tokens_field"] = "max_tokens" - } if _, ok := raw["discovery"]; !ok { raw["discovery"] = true } @@ -237,9 +234,6 @@ func applyProviderDefaults(providerID string, raw map[string]any) { if _, ok := transport["headers"]; !ok { transport["headers"] = map[string]any{} } - if _, ok := transport["options"]; !ok { - transport["options"] = map[string]any{} - } } modelsVal, exists := raw["models"] models, ok := modelsVal.(map[string]any) @@ -402,7 +396,6 @@ func rawToProvider(providerID string, raw map[string]any, bundledProvider bool) Transport: rawToTransport(transportRaw), SystemRole: SystemRole(stringValue(raw["system_role"], string(RoleSystem))), UsageInStream: boolValue(raw["usage_in_stream"], true), - MaxTokensField: stringValue(raw["max_tokens_field"], "max_tokens"), ExtraBody: anyMapValue(raw["extra_body"]), Discovery: boolValue(raw["discovery"], true), Hidden: boolValue(raw["hidden"], false), @@ -436,7 +429,6 @@ func rawToTransport(raw map[string]any) Transport { BaseURL: stringValue(raw["base_url"], ""), APIKeyEnv: stringValue(raw["api_key_env"], ""), Headers: stringMapValue(raw["headers"]), - Options: anyMapValue(raw["options"]), } } diff --git a/internal/catalog/build_test.go b/internal/catalog/build_test.go index 522a1131..ca73da22 100644 --- a/internal/catalog/build_test.go +++ b/internal/catalog/build_test.go @@ -696,6 +696,34 @@ func TestBuildSkipsReservedKeyViolationWithWarning(t *testing.T) { } } +func TestBuildSkipsRetiredProviderControlsWithWarning(t *testing.T) { + result := Build(BuildInputs{ + UserRaw: map[string]any{ + "retired": map[string]any{ + "transport": map[string]any{ + "base_url": "https://retired.test/v1", "api_key_env": "RETIRED_API_KEY", + "options": map[string]any{"retries": float64(3)}, + }, + "max_tokens_field": "max_completion_tokens", + "models": map[string]any{"m": map[string]any{"context_window": float64(4096)}}, + }, + "valid": map[string]any{ + "transport": map[string]any{"base_url": "http://valid.test/v1", "api_key_env": ""}, + "models": map[string]any{"m": map[string]any{"context_window": float64(4096)}}, + }, + }, + }) + if !hasWarningForProvider(result.Warnings, "user_config_skip", "retired") { + t.Fatalf("warnings = %#v, want user_config_skip for the retired controls", result.Warnings) + } + if _, ok := result.Catalog.Providers["retired"]; ok { + t.Fatal("a provider carrying retired controls stayed effective") + } + if _, ok := result.Catalog.Providers["valid"]; !ok { + t.Fatalf("valid sibling was omitted: %#v", result.Warnings) + } +} + func TestBuildDiscoveryUpdatesBundledCostFields(t *testing.T) { // Bundled model has cost with input=10, output=20. // Discovery cache has updated cost with input=15, output=30. diff --git a/internal/catalog/builtin/openai.json b/internal/catalog/builtin/openai.json index 449b0e33..8e56ec9d 100644 --- a/internal/catalog/builtin/openai.json +++ b/internal/catalog/builtin/openai.json @@ -7,7 +7,6 @@ }, "system_role": "system", "usage_in_stream": true, - "max_tokens_field": "max_completion_tokens", "discovery": true, "models": { "gpt-5.5": { diff --git a/internal/catalog/discovery.go b/internal/catalog/discovery.go index 8adab5bb..1f26e11c 100644 --- a/internal/catalog/discovery.go +++ b/internal/catalog/discovery.go @@ -8,8 +8,6 @@ import ( "encoding/json" "fmt" "io" - "math" - "math/big" "net/http" "net/url" "os" @@ -52,13 +50,13 @@ type DiscoveryRecord struct { Models map[string]DiscoveredModel } -// BoundTo reports whether the record belongs to the configured transport. +// BoundTo reports whether the record belongs to the configured transport. A +// record whose persisted fingerprint is empty is unbound. func (r DiscoveryRecord) BoundTo(transport Transport) bool { if r.TransportFingerprint == "" { return false } - fingerprint := transportFingerprint(transport) - return fingerprint != "" && r.TransportFingerprint == fingerprint + return r.TransportFingerprint == transportFingerprint(transport) } // SameTransport reports whether two configured transports have the same @@ -66,26 +64,13 @@ func (r DiscoveryRecord) BoundTo(transport Transport) bool { // provisional authorization header is excluded because callers pass the // captured configured transport, not the provisional fetch copy. func SameTransport(a, b Transport) bool { - aFingerprint := transportFingerprint(a) - bFingerprint := transportFingerprint(b) - return aFingerprint != "" && aFingerprint == bFingerprint + return transportFingerprint(a) == transportFingerprint(b) } // transportFingerprint returns the lowercase SHA-256 identity of a configured -// transport. An empty result means the options could not be represented as -// canonical JSON and is never a valid persisted fingerprint. +// transport: its base URL, credential binding and configured headers — the +// effective discovery inputs. Every configured transport yields one hash. func transportFingerprint(transport Transport) string { - options, err := canonicalJSONValue(transport.Options) - if err != nil { - return "" - } - if options == nil { - options = map[string]any{} - } - canonicalOptions, err := json.Marshal(options) - if err != nil { - return "" - } headers := map[string]string{} for name, value := range transport.Headers { digest := sha256.Sum256([]byte(value)) @@ -95,127 +80,16 @@ func transportFingerprint(transport Transport) string { BaseURL string `json:"base_url"` APIKeyEnv string `json:"api_key_env"` Headers map[string]string `json:"headers"` - Options json.RawMessage `json:"options"` }{ BaseURL: transport.BaseURL, APIKeyEnv: transport.APIKeyEnv, Headers: headers, - Options: canonicalOptions, - } - data, err := json.Marshal(payload) - if err != nil { - return "" } + data, _ := json.Marshal(payload) digest := sha256.Sum256(data) return hex.EncodeToString(digest[:]) } -// canonicalJSONValue normalizes JSON-shaped option values without converting -// numbers through float64. encoding/json sorts object keys while preserving -// array order, so the returned value can be serialized deterministically. -func canonicalJSONValue(value any) (any, error) { - switch typed := value.(type) { - case nil: - return nil, nil - case string, bool: - return typed, nil - case json.Number: - return canonicalNumber(string(typed)) - case float64: - if math.IsNaN(typed) || math.IsInf(typed, 0) { - return nil, fmt.Errorf("non-finite option number") - } - return canonicalNumber(strconv.FormatFloat(typed, 'g', -1, 64)) - case map[string]any: - out := make(map[string]any, len(typed)) - for key, item := range typed { - canonical, err := canonicalJSONValue(item) - if err != nil { - return nil, err - } - out[key] = canonical - } - return out, nil - case []any: - out := make([]any, len(typed)) - for i, item := range typed { - canonical, err := canonicalJSONValue(item) - if err != nil { - return nil, err - } - out[i] = canonical - } - return out, nil - default: - return nil, fmt.Errorf("unsupported option type %T", value) - } -} - -func canonicalNumber(value string) (json.Number, error) { - normalized, err := normalizeDecimal(value) - if err != nil { - return "", err - } - return json.Number(normalized), nil -} - -func normalizeDecimal(value string) (string, error) { - if !json.Valid([]byte(value)) { - return "", fmt.Errorf("invalid option number %q", value) - } - start := 0 - negative := false - if value[0] == '-' { - negative = true - start++ - } - mantissa := value[start:] - exponent := new(big.Int) - if index := strings.IndexAny(mantissa, "eE"); index >= 0 { - exponentText := mantissa[index+1:] - if strings.HasPrefix(exponentText, "+") { - exponentText = exponentText[1:] - } - if _, ok := exponent.SetString(exponentText, 10); !ok { - return "", fmt.Errorf("invalid option number %q", value) - } - mantissa = mantissa[:index] - } - parts := strings.Split(mantissa, ".") - if len(parts) > 2 || parts[0] == "" || (len(parts) == 2 && parts[1] == "") { - return "", fmt.Errorf("invalid option number %q", value) - } - digits := parts[0] - if len(parts) == 2 { - digits += parts[1] - } - for _, digit := range digits { - if digit < '0' || digit > '9' { - return "", fmt.Errorf("invalid option number %q", value) - } - } - scale := new(big.Int).Set(exponent) - if len(parts) == 2 { - scale.Sub(scale, big.NewInt(int64(len(parts[1])))) - } - digits = strings.TrimLeft(digits, "0") - if digits == "" { - return "0", nil - } - for strings.HasSuffix(digits, "0") { - digits = strings.TrimSuffix(digits, "0") - scale.Add(scale, big.NewInt(1)) - } - sign := "" - if negative { - sign = "-" - } - if scale.Sign() == 0 { - return sign + digits, nil - } - return sign + digits + "e" + scale.String(), nil -} - type discoveryCacheModel struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -649,16 +523,12 @@ func WriteDiscoveryAttempt(home, providerID string, transport Transport, attempt if !safeProviderID(providerID) { return fmt.Errorf("%w: provider id %q", ErrInvalidModelRef, providerID) } - fingerprint := transportFingerprint(transport) - if fingerprint == "" { - return fmt.Errorf("compute discovery transport fingerprint") - } if attemptedAt.IsZero() { attemptedAt = time.Now().UTC() } path := filepath.Join(discoveryCacheDir(home), providerID+".json") return withDiscoveryLock(home, providerID, func() error { - return writeDiscoveryAttemptPayload(path, fingerprint, attemptedAt) + return writeDiscoveryAttemptPayload(path, transportFingerprint(transport), attemptedAt) }) } @@ -672,17 +542,13 @@ func TryWriteDiscoveryAttempt(home, providerID string, transport Transport, atte if !safeProviderID(providerID) { return false, fmt.Errorf("%w: provider id %q", ErrInvalidModelRef, providerID) } - fingerprint := transportFingerprint(transport) - if fingerprint == "" { - return false, fmt.Errorf("compute discovery transport fingerprint") - } if attemptedAt.IsZero() { attemptedAt = time.Now().UTC() } path := filepath.Join(discoveryCacheDir(home), providerID+".json") return atomicfs.TryWithLock(discoveryLockPath(home, providerID), func() error { discoveryLockAcquiredHook(discoveryLockPath(home, providerID)) - return writeDiscoveryAttemptPayload(path, fingerprint, attemptedAt) + return writeDiscoveryAttemptPayload(path, transportFingerprint(transport), attemptedAt) }) } @@ -721,16 +587,12 @@ func WriteDiscoveryCache(home, providerID string, transport Transport, discovere if !safeProviderID(providerID) { return fmt.Errorf("%w: provider id %q", ErrInvalidModelRef, providerID) } - fingerprint := transportFingerprint(transport) - if fingerprint == "" { - return fmt.Errorf("compute discovery transport fingerprint") - } if fetchedAt.IsZero() { fetchedAt = time.Now().UTC() } path := filepath.Join(discoveryCacheDir(home), providerID+".json") return withDiscoveryLock(home, providerID, func() error { - return writeDiscoveryCachePayload(path, fingerprint, discovered, fetchedAt) + return writeDiscoveryCachePayload(path, transportFingerprint(transport), discovered, fetchedAt) }) } @@ -744,17 +606,13 @@ func TryWriteDiscoveryCache(home, providerID string, transport Transport, discov if !safeProviderID(providerID) { return false, fmt.Errorf("%w: provider id %q", ErrInvalidModelRef, providerID) } - fingerprint := transportFingerprint(transport) - if fingerprint == "" { - return false, fmt.Errorf("compute discovery transport fingerprint") - } if fetchedAt.IsZero() { fetchedAt = time.Now().UTC() } path := filepath.Join(discoveryCacheDir(home), providerID+".json") return atomicfs.TryWithLock(discoveryLockPath(home, providerID), func() error { discoveryLockAcquiredHook(discoveryLockPath(home, providerID)) - return writeDiscoveryCachePayload(path, fingerprint, discovered, fetchedAt) + return writeDiscoveryCachePayload(path, transportFingerprint(transport), discovered, fetchedAt) }) } diff --git a/internal/catalog/discovery_test.go b/internal/catalog/discovery_test.go index 960d9d76..98b6218a 100644 --- a/internal/catalog/discovery_test.go +++ b/internal/catalog/discovery_test.go @@ -357,13 +357,6 @@ func TestTransportFingerprintCanonicalIdentity(t *testing.T) { "X-A": "a-value", "Authorization": "Bearer first-secret", }, - Options: map[string]any{ - "nested": map[string]any{ - "large": json.Number("90071992547409931234567890"), - "decimal": json.Number("1.2300"), - }, - "array": []any{json.Number("1"), "1", true}, - }, } equivalent := Transport{ BaseURL: base.BaseURL, @@ -373,13 +366,6 @@ func TestTransportFingerprintCanonicalIdentity(t *testing.T) { "Authorization": "Bearer first-secret", "X-B": "b-value", }, - Options: map[string]any{ - "array": []any{json.Number("1.0"), "1", true}, - "nested": map[string]any{ - "decimal": json.Number("1.23000"), - "large": json.Number("90071992547409931234567890"), - }, - }, } if !SameTransport(base, equivalent) { t.Fatal("equivalent transports did not share a fingerprint") @@ -393,29 +379,22 @@ func TestTransportFingerprintCanonicalIdentity(t *testing.T) { if SameTransport(base, changedConfiguredAuthorization) { t.Fatal("different configured Authorization values shared a fingerprint") } - if SameTransport(base, Transport{BaseURL: base.BaseURL, APIKeyEnv: "OTHER_KEY", Headers: base.Headers, Options: base.Options}) { + if SameTransport(base, Transport{BaseURL: base.BaseURL, APIKeyEnv: "OTHER_KEY", Headers: base.Headers}) { t.Fatal("different API key environment names shared a fingerprint") } + if SameTransport(base, Transport{BaseURL: "https://other.test/v1", APIKeyEnv: base.APIKeyEnv, Headers: base.Headers}) { + t.Fatal("different base URLs shared a fingerprint") + } changedHeader := equivalent changedHeader.Headers = map[string]string{"X-A": "changed"} if SameTransport(base, changedHeader) { t.Fatal("different header values shared a fingerprint") } - changedArray := equivalent - changedArray.Options = map[string]any{"array": []any{true, "1", json.Number("1.0")}} - if SameTransport(base, changedArray) { - t.Fatal("different array order shared a fingerprint") - } - changedScalar := equivalent - changedScalar.Options = map[string]any{"array": []any{json.Number("1.0"), json.Number("1"), true}} - if SameTransport(base, changedScalar) { - t.Fatal("different scalar types shared a fingerprint") - } if !SameTransport( Transport{BaseURL: base.BaseURL, APIKeyEnv: base.APIKeyEnv}, - Transport{BaseURL: base.BaseURL, APIKeyEnv: base.APIKeyEnv, Headers: map[string]string{}, Options: map[string]any{}}, + Transport{BaseURL: base.BaseURL, APIKeyEnv: base.APIKeyEnv, Headers: map[string]string{}}, ) { - t.Fatal("nil and empty transport maps did not share a fingerprint") + t.Fatal("nil and empty transport headers did not share a fingerprint") } if len(transportFingerprint(base)) != sha256.Size*2 || strings.ToLower(transportFingerprint(base)) != transportFingerprint(base) { t.Fatalf("transport fingerprint = %q, want lowercase SHA-256 hex", transportFingerprint(base)) @@ -439,50 +418,6 @@ func TestTransportFingerprintIgnoresResolvedAPIKey(t *testing.T) { } } -func TestTransportFingerprintDecimalNormalization(t *testing.T) { - transportWithNumber := func(number string) Transport { - return Transport{Options: map[string]any{"number": json.Number(number)}} - } - for _, pair := range [][2]string{ - {"1e+2", "100"}, - {"123e-2", "1.23"}, - {"-1.20e-2", "-0.012"}, - {"-0e99999", "0"}, - {"90071992547409931234567890e-10", "9007199254740993.123456789"}, - {"1e+200000", "100e+199998"}, - {"1e-200000", "100e-200002"}, - } { - t.Run(pair[0]+"="+pair[1], func(t *testing.T) { - if !SameTransport(transportWithNumber(pair[0]), transportWithNumber(pair[1])) { - t.Fatalf("%s and %s did not normalize equally", pair[0], pair[1]) - } - }) - } - for _, pair := range [][2]string{ - {"1.23e2", "0.0123"}, - {"123e-2", "12300"}, - {"-1.20e-2", "-0.120"}, - } { - t.Run(pair[0]+"!="+pair[1], func(t *testing.T) { - if SameTransport(transportWithNumber(pair[0]), transportWithNumber(pair[1])) { - t.Fatalf("%s and %s collided", pair[0], pair[1]) - } - }) - } -} - -func TestTransportFingerprintFloat64AndJSONNumber(t *testing.T) { - transportWithNumber := func(number any) Transport { - return Transport{Options: map[string]any{"number": number}} - } - if !SameTransport(transportWithNumber(float64(1.25)), transportWithNumber(json.Number("125e-2"))) { - t.Fatal("float64 and json.Number did not normalize equally") - } - if SameTransport(transportWithNumber(float64(1.25)), transportWithNumber(json.Number("126e-2"))) { - t.Fatal("unequal float64 and json.Number values shared a fingerprint") - } -} - func TestDiscoveryRecordsAreBoundForModelsAndTTL(t *testing.T) { home := t.TempDir() transportA := Transport{BaseURL: "http://a.test/v1"} diff --git a/internal/catalog/loader.go b/internal/catalog/loader.go index c6bf8544..ab06d4d8 100644 --- a/internal/catalog/loader.go +++ b/internal/catalog/loader.go @@ -113,12 +113,8 @@ func (l *Loader) loadCapturedConnection(userRaw map[string]any, providerID strin } records, warnings := ReadDiscoveryCache(home) if discovered != nil { - fingerprint := transportFingerprint(transport) - if fingerprint == "" { - return nil, nil, fmt.Errorf("compute discovery transport fingerprint") - } records[providerID] = DiscoveryRecord{ - TransportFingerprint: fingerprint, + TransportFingerprint: transportFingerprint(transport), FetchedAt: time.Now().UTC(), AttemptedAt: time.Now().UTC(), Models: discovered.Models, diff --git a/internal/catalog/max_tokens_field_test.go b/internal/catalog/max_tokens_field_test.go deleted file mode 100644 index 90f4233d..00000000 --- a/internal/catalog/max_tokens_field_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package catalog - -import "testing" - -func TestBuildDefaultsAndOverridesMaxTokensField(t *testing.T) { - result := Build(BuildInputs{ - UserRaw: map[string]any{ - "openai": map[string]any{ - "transport": map[string]any{"base_url": "https://api.openai.com/v1", "api_key_env": "OPENAI_API_KEY"}, - "max_tokens_field": "max_completion_tokens", - "models": map[string]any{ - "gpt-5.4-mini": map[string]any{"context_window": float64(400000), "max_output_tokens": float64(128000)}, - }, - }, - "local": map[string]any{ - "transport": map[string]any{"base_url": "http://localhost:11434/v1", "api_key_env": ""}, - "models": map[string]any{ - "qwen": map[string]any{"context_window": float64(32768), "max_output_tokens": float64(8192)}, - }, - }, - }, - }) - if len(result.Warnings) != 0 { - t.Fatalf("Build warnings = %#v, want none", result.Warnings) - } - openAI, _, err := result.Catalog.Lookup(ModelRef{Provider: "openai", Model: "gpt-5.4-mini"}) - if err != nil { - t.Fatalf("Lookup openai/gpt-5.4-mini returned error: %v", err) - } - if openAI.MaxTokensField != "max_completion_tokens" { - t.Fatalf("openai MaxTokensField = %q, want max_completion_tokens", openAI.MaxTokensField) - } - local, _, err := result.Catalog.Lookup(ModelRef{Provider: "local", Model: "qwen"}) - if err != nil { - t.Fatalf("Lookup local/qwen returned error: %v", err) - } - if local.MaxTokensField != "max_tokens" { - t.Fatalf("local MaxTokensField = %q, want max_tokens default", local.MaxTokensField) - } -} - -func TestValidateRawRejectsInvalidMaxTokensField(t *testing.T) { - raw := validRawProvider() - raw["max_tokens_field"] = "max_output_tokens" - - errs := ValidateRaw("openai", raw, true) - if !hasValidationError(errs, "max_tokens_field") { - t.Fatalf("ValidateRaw errors = %#v, want max_tokens_field error", errs) - } -} diff --git a/internal/catalog/types.go b/internal/catalog/types.go index 061ea47d..ae51c972 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -45,7 +45,6 @@ type Transport struct { BaseURL string `json:"base_url"` APIKeyEnv string `json:"api_key_env"` Headers map[string]string `json:"headers,omitempty"` - Options map[string]any `json:"options,omitempty"` } // Cost is per-million-token USD pricing. @@ -89,7 +88,6 @@ type Provider struct { Transport Transport `json:"transport"` SystemRole SystemRole `json:"system_role"` UsageInStream bool `json:"usage_in_stream"` - MaxTokensField string `json:"max_tokens_field"` ExtraBody map[string]any `json:"extra_body,omitempty"` Discovery bool `json:"discovery"` Hidden bool `json:"hidden,omitempty"` diff --git a/internal/catalog/validate.go b/internal/catalog/validate.go index 1c2ae63c..db013aaa 100644 --- a/internal/catalog/validate.go +++ b/internal/catalog/validate.go @@ -84,7 +84,6 @@ func ValidateRaw(provID string, raw map[string]any, strict bool) []ValidationErr } validateRawSystemRole(add, "", "system_role", raw["system_role"]) validateRawBool(add, "", "usage_in_stream", raw["usage_in_stream"]) - validateRawMaxTokensField(add, raw["max_tokens_field"]) validateRawBool(add, "", "discovery", raw["discovery"]) validateRawBool(add, "", "hidden", raw["hidden"]) validateRawExtraBody(add, "", "extra_body", raw["extra_body"]) @@ -140,9 +139,6 @@ func ValidateEffective(p *Provider) []ValidationError { if !validSystemRole(p.SystemRole) { add("", "system_role", "must be system, user, or developer") } - if p.MaxTokensField != "" && !validMaxTokensField(p.MaxTokensField) { - add("", "max_tokens_field", "must be max_tokens or max_completion_tokens") - } for _, key := range CheckReservedKeys(p.ExtraBody) { add("", "extra_body."+key, "reserved request body key") } @@ -176,7 +172,7 @@ func ValidateEffective(p *Provider) []ValidationError { func validateRawTransport(add func(string, string, string), transport map[string]any) { for key := range transport { - if key != "base_url" && key != "api_key_env" && key != "headers" && key != "options" { + if key != "base_url" && key != "api_key_env" && key != "headers" { add("", "transport."+key, "unknown transport field") } } @@ -206,11 +202,6 @@ func validateRawTransport(add func(string, string, string), transport map[string } } } - if options, ok := transport["options"]; ok { - if _, ok := validationObject(options); !ok { - add("", "transport.options", "must be an object") - } - } } func validateRawModel(add func(string, string, string), modelID string, raw map[string]any, strict bool) { @@ -259,16 +250,6 @@ func validateRawSystemRole(add func(string, string, string), model, field string } } -func validateRawMaxTokensField(add func(string, string, string), value any) { - if value == nil { - return - } - s, ok := value.(string) - if !ok || !validMaxTokensField(s) { - add("", "max_tokens_field", "must be max_tokens or max_completion_tokens") - } -} - func validateRawBool(add func(string, string, string), model, field string, value any) { if value == nil { return @@ -426,7 +407,7 @@ func validateURL(add func(string, string, string), model, field, raw string) { func allowedRawProviderKey(key string) bool { switch key { - case "id", "name", "transport", "system_role", "usage_in_stream", "max_tokens_field", "extra_body", "discovery", "hidden", "protocol_metadata", "models": + case "id", "name", "transport", "system_role", "usage_in_stream", "extra_body", "discovery", "hidden", "protocol_metadata", "models": return true default: return false @@ -451,15 +432,6 @@ func validSystemRole(role SystemRole) bool { } } -func validMaxTokensField(field string) bool { - switch field { - case "max_tokens", "max_completion_tokens": - return true - default: - return false - } -} - func validModality(modality Modality) bool { switch modality { case ModalityText, ModalityImage, ModalityAudio, ModalityDocument: diff --git a/internal/catalog/validate_test.go b/internal/catalog/validate_test.go index 29bc36f9..7e322450 100644 --- a/internal/catalog/validate_test.go +++ b/internal/catalog/validate_test.go @@ -37,6 +37,24 @@ func TestValidateRawStrictRejectsUnknownProviderKey(t *testing.T) { } } +// TestValidateRawStrictRejectsRetiredProviderControls pins the retired +// controls' shared unknown-field policy: the removed max_tokens_field and +// transport.options members are ordinary unknown members now, refused by the +// same strict check as any other forged key. +func TestValidateRawStrictRejectsRetiredProviderControls(t *testing.T) { + raw := validRawProvider() + raw["max_tokens_field"] = "max_completion_tokens" + if errs := ValidateRaw("openai", raw, true); !hasValidationError(errs, "max_tokens_field") { + t.Fatalf("ValidateRaw errors = %#v, want the retired max_tokens_field refused as unknown", errs) + } + + raw = validRawProvider() + raw["transport"].(map[string]any)["options"] = map[string]any{"retries": float64(3)} + if errs := ValidateRaw("openai", raw, true); !hasValidationError(errs, "transport.options") { + t.Fatalf("ValidateRaw errors = %#v, want the retired transport.options refused as unknown", errs) + } +} + func TestValidateRawReportsMissingRequiredTransportBaseURL(t *testing.T) { raw := validRawProvider() delete(raw["transport"].(map[string]any), "base_url") @@ -148,7 +166,6 @@ func validRawProvider() map[string]any { "base_url": "https://api.openai.com/v1", "api_key_env": "OPENAI_API_KEY", "headers": map[string]any{"OpenAI-Beta": "assistants=v2"}, - "options": map[string]any{"timeout": float64(60)}, }, "system_role": "system", "usage_in_stream": true, diff --git a/internal/cliclient/globals_test.go b/internal/cliclient/globals_test.go index 0a35b692..c433088c 100644 --- a/internal/cliclient/globals_test.go +++ b/internal/cliclient/globals_test.go @@ -201,7 +201,7 @@ func TestConnectionRepairClearsOldFloorsBeforeNewOwnerHydration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/health" { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(protocol.Health{InstanceId: owner, ProtocolVersion: protocol.N1}) + _ = json.NewEncoder(w).Encode(protocol.Health{InstanceId: owner, ProtocolVersion: protocol.N2}) return } if r.URL.Path == "/v1/events" { @@ -223,7 +223,7 @@ func TestConnectionRepairClearsOldFloorsBeforeNewOwnerHydration(t *testing.T) { })) defer server.Close() dir := t.TempDir() - raw, _ := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": owner, "protocol_version": string(protocol.N1), "credential": strings.Repeat("1", 64)}) + raw, _ := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": owner, "protocol_version": string(protocol.N2), "credential": strings.Repeat("1", 64)}) if err := os.WriteFile(filepath.Join(dir, "runtime.json"), raw, 0600); err != nil { t.Fatal(err) } @@ -287,14 +287,14 @@ func TestConnectionRepairClearsOldFloorsBeforeNewOwnerHydration(t *testing.T) { } // opaqueOverflowConfiguration is one valid configuration document whose -// unused opaque extra_body and options members carry the overflow numeric -// lexemes as exact bytes, plus its malformed-delivery sibling body. +// unused opaque extra_body members carry the overflow numeric lexemes as +// exact bytes, plus its malformed-delivery sibling body. const opaqueOverflowConfiguration = `{"configuration_revision":{"instance_id":"` + instance + `","generation":"2"},` + `"settings":{"plugins":{}},"agents":[{"name":"primary","description":"primary agent"},{"name":"planner","description":"planning agent"}],` + `"providers":[{"id":"prov","name":"Prov","connected":true,` + - `"extra_body":{"n":9007199254740993},"options":{"big":1e400},` + + `"extra_body":{"n":9007199254740993,"big":1e400},` + `"models":[{"id":"m","name":"M","context_window":4096,"source":"user",` + - `"extra_body":{"n":9007199254740993},"options":{"big":1e400}}]}]}` + `"extra_body":{"n":9007199254740993,"big":1e400}}]}]}` // TestGlobalsAndAgentMenuOpaqueOverflowConfiguration drives the two // configuration consumers over the shared call decoder: the global read diff --git a/internal/cliclient/lifecycle_test.go b/internal/cliclient/lifecycle_test.go index 0d68f27a..a38f195a 100644 --- a/internal/cliclient/lifecycle_test.go +++ b/internal/cliclient/lifecycle_test.go @@ -94,7 +94,7 @@ func liveFixture(t *testing.T, active bool, special func(http.ResponseWriter, *h })) t.Cleanup(server.Close) dir := t.TempDir() - raw, _ := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N1), "credential": strings.Repeat("1", 64)}) + raw, _ := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N2), "credential": strings.Repeat("1", 64)}) if err := os.WriteFile(filepath.Join(dir, "runtime.json"), raw, 0600); err != nil { t.Fatal(err) } diff --git a/internal/cliclient/reader_test.go b/internal/cliclient/reader_test.go index 021ebd39..c53926bf 100644 --- a/internal/cliclient/reader_test.go +++ b/internal/cliclient/reader_test.go @@ -109,7 +109,7 @@ func discovery(t *testing.T, handler http.Handler) (clienthost.Options, *client. server := httptest.NewServer(handler) t.Cleanup(server.Close) dir := t.TempDir() - raw, _ := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N1), "credential": strings.Repeat("1", 64)}) + raw, _ := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N2), "credential": strings.Repeat("1", 64)}) path := filepath.Join(dir, "runtime.json") if err := os.WriteFile(path, raw, 0600); err != nil { t.Fatal(err) @@ -122,7 +122,7 @@ func discovery(t *testing.T, handler http.Handler) (clienthost.Options, *client. } func health(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(protocol.Health{InstanceId: instance, ProtocolVersion: protocol.N1}) + _ = json.NewEncoder(w).Encode(protocol.Health{InstanceId: instance, ProtocolVersion: protocol.N2}) } func fixture(t *testing.T) (*CLI, *scriptedTransport) { diff --git a/internal/clienthost/process_test.go b/internal/clienthost/process_test.go index 5b0c20b7..bb757d99 100644 --- a/internal/clienthost/process_test.go +++ b/internal/clienthost/process_test.go @@ -92,7 +92,7 @@ func runChild() { case roleFail: os.Exit(2) case roleIncompatible: - runShapedChild(dataDir, configPath, "2", false) + runShapedChild(dataDir, configPath, "1", false) case roleParked: runShapedChild(dataDir, configPath, "", true) default: @@ -619,7 +619,7 @@ func TestClientHostConcurrentStartup(t *testing.T) { t.Fatalf("client instance %q, record %q", clients[0].InstanceID(), record.InstanceID) } health := authenticatedHealth(t, record.Endpoint, record.Credential) - if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N2 { t.Fatalf("authenticated health = %s/%s, want the record's instance and wire version", health.InstanceId, health.ProtocolVersion) } @@ -685,7 +685,7 @@ func TestClientHostIncompatibleIncumbent(t *testing.T) { t.Fatal("the incompatible record changed across the probe") } health := authenticatedHealth(t, originalEndpoint, record.Credential) - if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N2 { t.Fatalf("the incumbent's real health = %s/%s", health.InstanceId, health.ProtocolVersion) } incumbent.stopAndJoin(t) @@ -768,7 +768,7 @@ func TestClientHostCancellationDoesNotKillOwner(t *testing.T) { t.Fatalf("record endpoint = %q, want the surviving owner %q", record.Endpoint, endpoint) } health := authenticatedHealth(t, record.Endpoint, record.Credential) - if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N2 { t.Fatalf("the surviving owner health = %s/%s", health.InstanceId, health.ProtocolVersion) } pid := attemptPID(t, attempts[0]) @@ -808,7 +808,7 @@ func TestClientHostHealthTimeoutPreservesHolder(t *testing.T) { } health := authenticatedHealth(t, originalEndpoint, record.Credential) - if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != record.InstanceID || health.ProtocolVersion != protocol.N2 { t.Fatalf("the preserved holder health = %s/%s", health.InstanceId, health.ProtocolVersion) } if after := readFile(t, recordPath); !bytes.Equal(after, before) { diff --git a/internal/clienthost/runmodels_test.go b/internal/clienthost/runmodels_test.go index 8d29184b..10258a2d 100644 --- a/internal/clienthost/runmodels_test.go +++ b/internal/clienthost/runmodels_test.go @@ -38,7 +38,7 @@ func modelsEndpoint(t *testing.T, status int, body []byte) clienthost.Options { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/health": - _ = json.NewEncoder(w).Encode(map[string]string{"instance_id": instance, "protocol_version": string(protocol.N1)}) + _ = json.NewEncoder(w).Encode(map[string]string{"instance_id": instance, "protocol_version": string(protocol.N2)}) case "/v1/configuration": w.WriteHeader(status) _, _ = w.Write(body) @@ -58,7 +58,7 @@ func modelsEndpoint(t *testing.T, status int, body []byte) clienthost.Options { } }) dir := t.TempDir() - record, err := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N1), "credential": credential}) + record, err := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N2), "credential": credential}) if err != nil { t.Fatal(err) } @@ -198,13 +198,13 @@ func TestRunModelsConfigurationFields(t *testing.T) { } // TestRunModelsOpaqueOverflowFieldsRenders proves unused opaque extra_body -// and options numbers beyond Number precision, delivered as their exact raw -// lexemes through the shared invocation, do not disturb the utility's -// retained output. +// numbers beyond Number precision, delivered as their exact raw lexemes +// through the shared invocation, do not disturb the utility's retained +// output. func TestRunModelsOpaqueOverflowFieldsRenders(t *testing.T) { // Raw JSON supplies the overflow lexemes exactly; a generated struct // cannot carry them. - raw := `{"configuration_revision":{"instance_id":"` + strings.Repeat("a", 32) + `","generation":"1"},"settings":{},"agents":[{"name":"primary","model":"prov/m"}],"providers":[{"id":"prov","connected":true,"extra_body":{"n":9007199254740993},"options":{"big":1e400},"models":[{"id":"m","name":"M","context_window":4096,"source":"user","extra_body":{"n":9007199254740993},"options":{"big":1e400}}]}]}` + raw := `{"configuration_revision":{"instance_id":"` + strings.Repeat("a", 32) + `","generation":"1"},"settings":{},"agents":[{"name":"primary","model":"prov/m"}],"providers":[{"id":"prov","connected":true,"extra_body":{"n":9007199254740993,"big":1e400},"models":[{"id":"m","name":"M","context_window":4096,"source":"user","extra_body":{"n":9007199254740993,"big":1e400}}]}]}` opts := modelsEndpoint(t, http.StatusOK, []byte(raw)) var out, diagnostics bytes.Buffer if err := clienthost.RunModels(context.Background(), opts, "", false, false, &out, &diagnostics); err != nil { diff --git a/internal/clientops/clientops.go b/internal/clientops/clientops.go index 4da3616a..b74b0803 100644 --- a/internal/clientops/clientops.go +++ b/internal/clientops/clientops.go @@ -110,55 +110,9 @@ func decodeError(raw json.RawMessage) (protocol.Error, error) { return payload, nil } -// operationNames is the one static list of supported invocation names in -// table order; it is the sole authority for the initialize advertisement and -// for Call's own admission. -var operationNames = []string{ - "GetHealth", - "ListSessions", - "CreateSession", - "DeleteSession", - "ArchiveSession", - "ReopenSession", - "SubmitSession", - "CompactSession", - "ForkSession", - "SetSessionAgentType", - "InterruptSession", - "StopSession", - "GetSessionHydration", - "GetSessionHistory", - "GetSessionCodeSnapshots", - "RevertSessionCode", - "ListWorkspaces", - "ReadWorkspaceFile", - "GetRetainedCodeSnapshots", - "RevertRetainedCode", - "GetConfiguration", - "ReloadConfiguration", - "UpdateConfigurationSettings", - "SetAgentTypeModel", - "ListModels", - "ListProviders", - "CreateProvider", - "GetProviderDetail", - "UpdateProviderDetail", - "DeleteProviderDetail", - "DiscoverProviderCandidates", - "ConnectProvider", - "DisconnectProvider", - "ResetProviderField", - "ListProviderModels", - "UpdateProviderModel", - "DeleteProviderModel", - "DiscoverProviderModelCandidates", - "ResetProviderModelField", - "GetWarnings", -} - -// OperationNames returns a fresh copy of the supported invocation names. It -// is presentation-only (CLI help and the ACP advertisement); admission and -// dispatch own unknown-name rejection, never this list. +// OperationNames returns a fresh copy of the supported invocation names in +// sorted order. It is presentation-only (CLI help and the ACP advertisement); +// admission and dispatch own unknown-name rejection, never this list. func OperationNames() []string { return append([]string(nil), operationNames...) } @@ -171,367 +125,21 @@ var ErrInvalidResponse = errors.New("invalid runtime response") // Invoke performs one generated Runtime operation through the supplied // authenticated client and returns its lossless result: the received HTTP -// status and the exact body bytes. The envelope carries only the addressing -// the operation declares: session_id for a schema Session path, field for the -// two reset path enums, and query/body where the operation declares them. A -// received valid response returns its actual status and exact body bytes; a +// status and the exact body bytes. The schema-generated dispatch in +// operations.gen.go binds each operation's declared addressing and calls its +// generated client method; the envelope carries only that addressing: +// session_id for a schema Session path, field for the two reset path enums, +// and query/body where the operation declares them. A received valid +// response returns its actual status and exact body bytes; a // valid 204 returns its status and the empty HTTP body. A declared non-2xx // rejection returns those same status/body bytes plus the ResponseError. A // local binding failure or a failure to obtain an HTTP response returns zero // status, no body and its ordinary error; once an HTTP response exists, its // status and available body bytes are retained even on failure. func Invoke(ctx context.Context, c *client.Client, operation string, params json.RawMessage) (int, json.RawMessage, error) { - var ( - response *http.Response - expected int - callErr error - ) - switch operation { - case "GetHealth": - if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { - return 0, nil, err - } - response, callErr = c.GetHealth(ctx) - expected = http.StatusOK - case "ListSessions": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.ListSessionsParams - if err := bindQuery(env.query, []string{"workspace", "lifecycle"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.ListSessions(ctx, &query) - expected = http.StatusOK - case "CreateSession": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.CreateSessionWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusCreated - case "DeleteSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.DeleteSession(ctx, protocol.SessionID(env.session)) - expected = http.StatusNoContent - case "ArchiveSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.ArchiveSession(ctx, protocol.SessionID(env.session)) - expected = http.StatusOK - case "ReopenSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.ReopenSession(ctx, protocol.SessionID(env.session)) - expected = http.StatusOK - case "SubmitSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.SubmitSessionWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "CompactSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.CompactSessionWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "ForkSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.ForkSessionWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "SetSessionAgentType": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.SetSessionAgentTypeWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "InterruptSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.InterruptSession(ctx, protocol.SessionID(env.session)) - expected = http.StatusNoContent - case "StopSession": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.StopSession(ctx, protocol.SessionID(env.session)) - expected = http.StatusNoContent - case "GetSessionHydration": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.GetSessionHydration(ctx, protocol.SessionID(env.session)) - expected = http.StatusOK - case "GetSessionHistory": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberOptional, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.GetSessionHistoryParams - if env.query != nil { - if err := bindQuery(env.query, nil, &query); err != nil { - return 0, nil, err - } - } - response, callErr = c.GetSessionHistory(ctx, protocol.SessionID(env.session), &query) - expected = http.StatusOK - case "GetSessionCodeSnapshots": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) - if err != nil { - return 0, nil, err - } - response, callErr = c.GetSessionCodeSnapshots(ctx, protocol.SessionID(env.session)) - expected = http.StatusOK - case "RevertSessionCode": - env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.RevertSessionCodeWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "ListWorkspaces": - if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { - return 0, nil, err - } - response, callErr = c.ListWorkspaces(ctx) - expected = http.StatusOK - case "ReadWorkspaceFile": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.ReadWorkspaceFileWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "GetRetainedCodeSnapshots": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.GetRetainedCodeSnapshotsParams - if err := bindQuery(env.query, []string{"workspace", "session_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.GetRetainedCodeSnapshots(ctx, &query) - expected = http.StatusOK - case "RevertRetainedCode": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.RevertRetainedCodeWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "GetConfiguration": - if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { - return 0, nil, err - } - response, callErr = c.GetConfiguration(ctx) - expected = http.StatusOK - case "ReloadConfiguration": - if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { - return 0, nil, err - } - response, callErr = c.ReloadConfiguration(ctx) - expected = http.StatusOK - case "UpdateConfigurationSettings": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.UpdateConfigurationSettingsWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "SetAgentTypeModel": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) - if err != nil { - return 0, nil, err - } - var query protocol.SetAgentTypeModelParams - if err := bindQuery(env.query, []string{"agent_type"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.SetAgentTypeModelWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "ListModels": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.ListModelsParams - if err := bindQuery(env.query, []string{"all"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.ListModels(ctx, &query) - expected = http.StatusOK - case "ListProviders": - if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { - return 0, nil, err - } - response, callErr = c.ListProviders(ctx) - expected = http.StatusOK - case "CreateProvider": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.CreateProviderWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "GetProviderDetail": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.GetProviderDetailParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.GetProviderDetail(ctx, &query) - expected = http.StatusOK - case "UpdateProviderDetail": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) - if err != nil { - return 0, nil, err - } - var query protocol.UpdateProviderDetailParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.UpdateProviderDetailWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "DeleteProviderDetail": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.DeleteProviderDetailParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.DeleteProviderDetail(ctx, &query) - expected = http.StatusOK - case "DiscoverProviderCandidates": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) - if err != nil { - return 0, nil, err - } - response, callErr = c.DiscoverProviderCandidatesWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "ConnectProvider": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) - if err != nil { - return 0, nil, err - } - var query protocol.ConnectProviderParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.ConnectProviderWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "DisconnectProvider": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.DisconnectProviderParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.DisconnectProvider(ctx, &query) - expected = http.StatusOK - case "ResetProviderField": - env, err := bindEnvelope(params, memberForbidden, memberRequired, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.ResetProviderFieldParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.ResetProviderField(ctx, protocol.ProviderField(env.field), &query) - expected = http.StatusOK - case "ListProviderModels": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.ListProviderModelsParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.ListProviderModels(ctx, &query) - expected = http.StatusOK - case "UpdateProviderModel": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) - if err != nil { - return 0, nil, err - } - var query protocol.UpdateProviderModelParams - if err := bindQuery(env.query, []string{"provider_id", "model_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.UpdateProviderModelWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) - expected = http.StatusOK - case "DeleteProviderModel": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.DeleteProviderModelParams - if err := bindQuery(env.query, []string{"provider_id", "model_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.DeleteProviderModel(ctx, &query) - expected = http.StatusOK - case "DiscoverProviderModelCandidates": - env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.DiscoverProviderModelCandidatesParams - if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.DiscoverProviderModelCandidates(ctx, &query) - expected = http.StatusOK - case "ResetProviderModelField": - env, err := bindEnvelope(params, memberForbidden, memberRequired, memberRequired, memberForbidden) - if err != nil { - return 0, nil, err - } - var query protocol.ResetProviderModelFieldParams - if err := bindQuery(env.query, []string{"provider_id", "model_id"}, &query); err != nil { - return 0, nil, err - } - response, callErr = c.ResetProviderModelField(ctx, protocol.ModelField(env.field), &query) - expected = http.StatusOK - case "GetWarnings": - if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { - return 0, nil, err - } - response, callErr = c.GetWarnings(ctx) - expected = http.StatusOK - default: - return 0, nil, invalidParams("unknown operation %q", operation) - } - if callErr != nil { - return 0, nil, callErr + response, expected, err := invokeGenerated(ctx, c, operation, params) + if err != nil { + return 0, nil, err } return receive(response, expected) } diff --git a/internal/clientops/clientops_test.go b/internal/clientops/clientops_test.go index 1e3c39c5..e18fd2f2 100644 --- a/internal/clientops/clientops_test.go +++ b/internal/clientops/clientops_test.go @@ -7,9 +7,14 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" + "sort" "strings" "testing" + "github.com/getkin/kin-openapi/openapi3" + "github.com/MMinasyan/lightcode/client" "github.com/MMinasyan/lightcode/internal/clientops" "github.com/MMinasyan/lightcode/protocol" @@ -100,7 +105,7 @@ type dispatchCase struct { var dispatchCases = []dispatchCase{ { name: "GetHealth", operation: "GetHealth", - status: 200, response: `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"1"}`, + status: 200, response: `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"2"}`, method: "GET", path: "/v1/health", }, { @@ -244,8 +249,8 @@ var dispatchCases = []dispatchCase{ body: `{"session_id":"0123456789abcdef0123456789abcdef","turn":1}`, }, { - name: "GetConfiguration with opaque overflow extra_body and options numbers", operation: "GetConfiguration", - status: 200, response: `{"configuration_revision":{"instance_id":"0123456789abcdef0123456789abcdef","generation":"1"},"providers":[{"id":"prov","connected":true,"extra_body":{"n":9007199254740993},"options":{"big":1e400},"models":[{"id":"m","context_window":8192,"extra_body":{"n":9007199254740993},"source":"user"}]}]}`, + name: "GetConfiguration with opaque overflow extra_body numbers", operation: "GetConfiguration", + status: 200, response: `{"configuration_revision":{"instance_id":"0123456789abcdef0123456789abcdef","generation":"1"},"providers":[{"id":"prov","connected":true,"extra_body":{"n":9007199254740993,"big":1e400},"models":[{"id":"m","context_window":8192,"extra_body":{"n":9007199254740993},"source":"user"}]}]}`, method: "GET", path: "/v1/configuration", }, { @@ -299,17 +304,17 @@ var dispatchCases = []dispatchCase{ }, { name: "UpdateProviderDetail", operation: "UpdateProviderDetail", - params: `{"query":{"provider_id":"prov"},"body":{"options":{"k":1}}}`, + params: `{"query":{"provider_id":"prov"},"body":{"name":"Edited"}}`, status: 200, response: `{"provider":{"id":"prov"}}`, method: "PUT", path: "/v1/providers/detail", rawQuery: "provider_id=prov", - body: `{"options":{"k":1}}`, + body: `{"name":"Edited"}`, }, { - name: "UpdateProviderDetail with opaque overflow extra_body and options", operation: "UpdateProviderDetail", - params: `{"query":{"provider_id":"prov"},"body":{"extra_body":{"n":9007199254740993},"options":{"big":1e400}}}`, - status: 200, response: `{"provider":{"id":"prov","extra_body":{"n":9007199254740993},"options":{"big":1e400}}}`, + name: "UpdateProviderDetail with opaque overflow extra_body", operation: "UpdateProviderDetail", + params: `{"query":{"provider_id":"prov"},"body":{"extra_body":{"n":9007199254740993,"big":1e400}}}`, + status: 200, response: `{"provider":{"id":"prov","extra_body":{"n":9007199254740993,"big":1e400}}}`, method: "PUT", path: "/v1/providers/detail", rawQuery: "provider_id=prov", - body: `{"extra_body":{"n":9007199254740993},"options":{"big":1e400}}`, + body: `{"extra_body":{"n":9007199254740993,"big":1e400}}`, }, { name: "DeleteProviderDetail", operation: "DeleteProviderDetail", @@ -446,7 +451,7 @@ func TestCallAcceptsEmptyEnvelopePositives(t *testing.T) { for _, params := range []json.RawMessage{nil, json.RawMessage(`{}`)} { c, doer := newScriptFixture(t) doer.respond = func(*http.Request) (*http.Response, error) { - return scriptedResponse(http.StatusOK, `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"1"}`), nil + return scriptedResponse(http.StatusOK, `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"2"}`), nil } result, err := clientops.Call(context.Background(), c, "GetHealth", params) if err != nil { @@ -527,12 +532,147 @@ func TestCallRejectsForbiddenEnvelope(t *testing.T) { // coverage lives in the invocation-table test. func TestOperationNames(t *testing.T) { names := clientops.OperationNames() + if len(names) == 0 { + t.Fatal("OperationNames() is empty") + } + first := names[0] names[0] = "Mutated" - if again := clientops.OperationNames(); again[0] != "GetHealth" { + if again := clientops.OperationNames(); again[0] != first { t.Fatalf("OperationNames() returned shared state: %q", again[0]) } } +// TestInvocationSurfaceMatchesSchema derives the native operation set from the +// committed schema itself — every declared operation whose success content is +// not the event stream — and requires the advertisement, the captured dispatch +// table and that derived set to agree in both directions. A schema addition +// without a dispatch case, or a dispatch case for an operation the schema does +// not declare, fails here independently of the generated table. +func TestInvocationSurfaceMatchesSchema(t *testing.T) { + doc := schemaDocument(t) + declared := map[string]bool{} + streamed := map[string]bool{} + for _, path := range sortedKeys(doc.Paths.Map()) { + item := doc.Paths.Map()[path] + methods := item.Operations() + for _, method := range sortedKeys(methods) { + operation := methods[method] + if operation.OperationID == "" { + t.Fatalf("%s %s: operation has no operationId", method, path) + } + if declared[operation.OperationID] || streamed[operation.OperationID] { + t.Fatalf("operation %q is declared twice", operation.OperationID) + } + if operationDeclaresStream(t, operation) { + streamed[operation.OperationID] = true + continue + } + declared[operation.OperationID] = true + } + } + if len(declared) == 0 || len(streamed) == 0 { + t.Fatalf("schema declares %d native operations and %d event streams; both sets must be non-empty", len(declared), len(streamed)) + } + names := clientops.OperationNames() + if !sort.StringsAreSorted(names) { + t.Errorf("OperationNames() is not sorted: %v", names) + } + advertised := map[string]bool{} + for _, name := range names { + if !declared[name] { + t.Errorf("advertised operation %q is not a declared native operation", name) + } + if advertised[name] { + t.Errorf("advertised operation %q appears twice", name) + } + advertised[name] = true + } + dispatched := map[string]bool{} + for _, tc := range dispatchCases { + if !declared[tc.operation] { + t.Errorf("dispatch case %q names %q, which is not a declared native operation", tc.name, tc.operation) + } + dispatched[tc.operation] = true + } + for name := range declared { + if !advertised[name] { + t.Errorf("declared native operation %q is not advertised", name) + } + if !dispatched[name] { + t.Errorf("declared native operation %q has no dispatch case", name) + } + } + for name := range streamed { + if advertised[name] { + t.Errorf("declared event stream %q is advertised as a native operation", name) + } + } + // The test-only native invocation contract is the second generated + // artifact: its root must name exactly the schema's native operations and + // carry no stream entry. + contractData, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "generated", "native-invocations.json")) + if err != nil { + t.Fatalf("reading the native invocation contract: %v", err) + } + var contract map[string]json.RawMessage + if err := json.Unmarshal(contractData, &contract); err != nil { + t.Fatalf("decoding the native invocation contract: %v", err) + } + for name := range declared { + if _, ok := contract[name]; !ok { + t.Errorf("declared native operation %q is missing from the native invocation contract", name) + } + } + for name := range contract { + if !declared[name] { + t.Errorf("the native invocation contract names %q, which is not a declared native operation", name) + } + } +} + +// operationDeclaresStream reports whether one declared operation's success +// content is the event stream. +func operationDeclaresStream(t *testing.T, operation *openapi3.Operation) bool { + t.Helper() + for status, response := range operation.Responses.Map() { + if len(status) != 3 || status[0] != '2' { + continue + } + if response.Value == nil { + t.Fatalf("operation %s success status %s is unresolved", operation.OperationID, status) + } + if response.Value.Content["text/event-stream"] != nil { + return true + } + } + return false +} + +// schemaDocument loads and validates the committed protocol schema for the +// calling test. +func schemaDocument(t *testing.T) *openapi3.T { + t.Helper() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile(filepath.Join("..", "..", "protocol", "openapi.yaml")) + if err != nil { + t.Fatalf("loading the protocol schema: %v", err) + } + if err := doc.Validate(context.Background()); err != nil { + t.Fatalf("validating the protocol schema: %v", err) + } + return doc +} + +// sortedKeys returns one string-keyed map's keys in sorted order. +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + // TestInvokeMapsDeclaredErrors drives the shared response owner over the // accepting fixture: a declared non-2xx body is either the Error or the // partial code-revert result, each returned beside the retained received diff --git a/internal/clientops/operations.gen.go b/internal/clientops/operations.gen.go new file mode 100644 index 00000000..a4ff02c2 --- /dev/null +++ b/internal/clientops/operations.gen.go @@ -0,0 +1,455 @@ +// Code generated by internal/protocolgen from protocol/openapi.yaml; DO NOT EDIT. +// +// The native operation table and its dispatch are derived from the schema: +// operationNames is the sorted set of declared native operations and +// invokeGenerated binds each one's declared addressing before calling the +// matching generated client method. +package clientops + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + + "github.com/MMinasyan/lightcode/client" + "github.com/MMinasyan/lightcode/protocol" +) + +// operationNames is the schema's native operation set in sorted order: every +// declared operation whose success response is not the event stream. It is the +// sole authority for the initialize advertisement and for Call's own admission. +var operationNames = []string{ + "ArchiveSession", + "CompactSession", + "ConnectProvider", + "CreateProvider", + "CreateSession", + "DeleteProviderDetail", + "DeleteProviderModel", + "DeleteSession", + "DisconnectProvider", + "DiscoverProviderCandidates", + "DiscoverProviderModelCandidates", + "ForkSession", + "GetConfiguration", + "GetHealth", + "GetProviderDetail", + "GetRetainedCodeSnapshots", + "GetSessionCodeSnapshots", + "GetSessionHistory", + "GetSessionHydration", + "GetWarnings", + "InterruptSession", + "ListModels", + "ListProviderModels", + "ListProviders", + "ListSessions", + "ListWorkspaces", + "ReadWorkspaceFile", + "ReloadConfiguration", + "ReopenSession", + "ResetProviderField", + "ResetProviderModelField", + "RevertRetainedCode", + "RevertSessionCode", + "SetAgentTypeModel", + "SetSessionAgentType", + "StopSession", + "SubmitSession", + "UpdateConfigurationSettings", + "UpdateProviderDetail", + "UpdateProviderModel", +} + +// invokeGenerated binds one invocation envelope to the generated client +// method the named operation declares and returns that call's raw response +// beside the operation's declared success status. A local binding failure or a +// failure to obtain an HTTP response returns a nil response and the zero +// status; the caller owns response interpretation. +func invokeGenerated(ctx context.Context, c *client.Client, operation string, params json.RawMessage) (*http.Response, int, error) { + switch operation { + case "ArchiveSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.ArchiveSession(ctx, protocol.SessionID(env.session)) + return response, 200, callErr + + case "CompactSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.CompactSessionWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "ConnectProvider": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) + if err != nil { + return nil, 0, err + } + var query protocol.ConnectProviderParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.ConnectProviderWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "CreateProvider": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.CreateProviderWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "CreateSession": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.CreateSessionWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) + return response, 201, callErr + + case "DeleteProviderDetail": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.DeleteProviderDetailParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.DeleteProviderDetail(ctx, &query) + return response, 200, callErr + + case "DeleteProviderModel": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.DeleteProviderModelParams + if err := bindQuery(env.query, []string{"provider_id", "model_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.DeleteProviderModel(ctx, &query) + return response, 200, callErr + + case "DeleteSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.DeleteSession(ctx, protocol.SessionID(env.session)) + return response, 204, callErr + + case "DisconnectProvider": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.DisconnectProviderParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.DisconnectProvider(ctx, &query) + return response, 200, callErr + + case "DiscoverProviderCandidates": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.DiscoverProviderCandidatesWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "DiscoverProviderModelCandidates": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.DiscoverProviderModelCandidatesParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.DiscoverProviderModelCandidates(ctx, &query) + return response, 200, callErr + + case "ForkSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.ForkSessionWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "GetConfiguration": + if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { + return nil, 0, err + } + response, callErr := c.GetConfiguration(ctx) + return response, 200, callErr + + case "GetHealth": + if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { + return nil, 0, err + } + response, callErr := c.GetHealth(ctx) + return response, 200, callErr + + case "GetProviderDetail": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.GetProviderDetailParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.GetProviderDetail(ctx, &query) + return response, 200, callErr + + case "GetRetainedCodeSnapshots": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.GetRetainedCodeSnapshotsParams + if err := bindQuery(env.query, []string{"workspace", "session_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.GetRetainedCodeSnapshots(ctx, &query) + return response, 200, callErr + + case "GetSessionCodeSnapshots": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.GetSessionCodeSnapshots(ctx, protocol.SessionID(env.session)) + return response, 200, callErr + + case "GetSessionHistory": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberOptional, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.GetSessionHistoryParams + if env.query != nil { + if err := bindQuery(env.query, nil, &query); err != nil { + return nil, 0, err + } + } + response, callErr := c.GetSessionHistory(ctx, protocol.SessionID(env.session), &query) + return response, 200, callErr + + case "GetSessionHydration": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.GetSessionHydration(ctx, protocol.SessionID(env.session)) + return response, 200, callErr + + case "GetWarnings": + if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { + return nil, 0, err + } + response, callErr := c.GetWarnings(ctx) + return response, 200, callErr + + case "InterruptSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.InterruptSession(ctx, protocol.SessionID(env.session)) + return response, 204, callErr + + case "ListModels": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.ListModelsParams + if err := bindQuery(env.query, []string{"all"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.ListModels(ctx, &query) + return response, 200, callErr + + case "ListProviderModels": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.ListProviderModelsParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.ListProviderModels(ctx, &query) + return response, 200, callErr + + case "ListProviders": + if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { + return nil, 0, err + } + response, callErr := c.ListProviders(ctx) + return response, 200, callErr + + case "ListSessions": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.ListSessionsParams + if err := bindQuery(env.query, []string{"workspace", "lifecycle"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.ListSessions(ctx, &query) + return response, 200, callErr + + case "ListWorkspaces": + if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { + return nil, 0, err + } + response, callErr := c.ListWorkspaces(ctx) + return response, 200, callErr + + case "ReadWorkspaceFile": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.ReadWorkspaceFileWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "ReloadConfiguration": + if _, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberForbidden); err != nil { + return nil, 0, err + } + response, callErr := c.ReloadConfiguration(ctx) + return response, 200, callErr + + case "ReopenSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.ReopenSession(ctx, protocol.SessionID(env.session)) + return response, 200, callErr + + case "ResetProviderField": + env, err := bindEnvelope(params, memberForbidden, memberRequired, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.ResetProviderFieldParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.ResetProviderField(ctx, protocol.ProviderField(env.field), &query) + return response, 200, callErr + + case "ResetProviderModelField": + env, err := bindEnvelope(params, memberForbidden, memberRequired, memberRequired, memberForbidden) + if err != nil { + return nil, 0, err + } + var query protocol.ResetProviderModelFieldParams + if err := bindQuery(env.query, []string{"provider_id", "model_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.ResetProviderModelField(ctx, protocol.ModelField(env.field), &query) + return response, 200, callErr + + case "RevertRetainedCode": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.RevertRetainedCodeWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "RevertSessionCode": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.RevertSessionCodeWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "SetAgentTypeModel": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) + if err != nil { + return nil, 0, err + } + var query protocol.SetAgentTypeModelParams + if err := bindQuery(env.query, []string{"agent_type"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.SetAgentTypeModelWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "SetSessionAgentType": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.SetSessionAgentTypeWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "StopSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberForbidden) + if err != nil { + return nil, 0, err + } + response, callErr := c.StopSession(ctx, protocol.SessionID(env.session)) + return response, 204, callErr + + case "SubmitSession": + env, err := bindEnvelope(params, memberRequired, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.SubmitSessionWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "UpdateConfigurationSettings": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberForbidden, memberRequired) + if err != nil { + return nil, 0, err + } + response, callErr := c.UpdateConfigurationSettingsWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "UpdateProviderDetail": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) + if err != nil { + return nil, 0, err + } + var query protocol.UpdateProviderDetailParams + if err := bindQuery(env.query, []string{"provider_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.UpdateProviderDetailWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + case "UpdateProviderModel": + env, err := bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberRequired) + if err != nil { + return nil, 0, err + } + var query protocol.UpdateProviderModelParams + if err := bindQuery(env.query, []string{"provider_id", "model_id"}, &query); err != nil { + return nil, 0, err + } + response, callErr := c.UpdateProviderModelWithBody(ctx, &query, contentTypeJSON, bytes.NewReader(env.body)) + return response, 200, callErr + + default: + return nil, 0, invalidParams("unknown operation %q", operation) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1cf8cb71..514be120 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -56,16 +56,16 @@ func TestParseAcceptsEmptySkeleton(t *testing.T) { } } -func TestParsePreservesExactProviderOptionNumbers(t *testing.T) { +func TestParsePreservesExactProviderExtraBodyNumbers(t *testing.T) { cfg, err := Parse([]byte(` { "providers": { "local": { "transport": { "base_url": "http://localhost:11434/v1", - "api_key_env": "", - "options": {"large": 90071992547409931234567890} - } + "api_key_env": "" + }, + "extra_body": {"large": 90071992547409931234567890} } } } @@ -75,10 +75,9 @@ func TestParsePreservesExactProviderOptionNumbers(t *testing.T) { t.Fatal(err) } providers := cfg.Providers["local"].(map[string]any) - transport := providers["transport"].(map[string]any) - options := transport["options"].(map[string]any) - if got, ok := options["large"].(json.Number); !ok || got.String() != "90071992547409931234567890" { - t.Fatalf("provider option number = %#v (%T), want exact json.Number", options["large"], options["large"]) + extraBody := providers["extra_body"].(map[string]any) + if got, ok := extraBody["large"].(json.Number); !ok || got.String() != "90071992547409931234567890" { + t.Fatalf("provider extra_body number = %#v (%T), want exact json.Number", extraBody["large"], extraBody["large"]) } } diff --git a/internal/desktopclient/host_test.go b/internal/desktopclient/host_test.go index 147601c5..13afc727 100644 --- a/internal/desktopclient/host_test.go +++ b/internal/desktopclient/host_test.go @@ -40,7 +40,7 @@ const ( // recordContent builds one private discovery document pointing at the // fixture endpoint. func recordContent(endpoint string) string { - return fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"1","credential":%q}`, + return fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"2","credential":%q}`, endpoint, testInstance, testCredential) } @@ -69,7 +69,7 @@ func newFixture(t *testing.T, health, events http.HandlerFunc, extra ...map[stri func jsonHealth() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"1"}`, testInstance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"2"}`, testInstance) } } @@ -839,7 +839,7 @@ func (f *gatedFixture) health(w http.ResponseWriter, r *http.Request) { } } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"1"}`, testInstance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"2"}`, testInstance) } func (f *gatedFixture) events(w http.ResponseWriter, r *http.Request) { @@ -963,7 +963,7 @@ func TestDesktopHostFailedOpenDoesNotResurrectOldStamp(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"1"}`, testInstance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"2"}`, testInstance) } server := newFixture(t, failingHealth, parkedStream()) host := newHost(t, server) @@ -1145,7 +1145,7 @@ func TestDesktopHostMountedRuntime(t *testing.T) { if err := json.Unmarshal([]byte(response.Body), &health); err != nil { t.Fatalf("decode health body: %v", err) } - if health.InstanceId != info.InstanceID || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != info.InstanceID || health.ProtocolVersion != protocol.N2 { t.Fatalf("health = %+v, want the connected owner", health) } diff --git a/internal/protocolgen/main.go b/internal/protocolgen/main.go new file mode 100644 index 00000000..db101426 --- /dev/null +++ b/internal/protocolgen/main.go @@ -0,0 +1,621 @@ +// Command protocolgen generates the native invocation bindings and their +// test-only invocation contract from the protocol schema. It is a private +// build command: it runs during generation, imports the pinned schema loader +// and the standard library, and never enters a shipped Runtime, Harness, +// client or plugin path. +// +// go run ./internal/protocolgen \ +// -schema protocol/openapi.yaml \ +// -out internal/clientops/operations.gen.go \ +// -native-contract-out frontend/src/generated/native-invocations.json +// +// Both artifacts are resolved, validated and formatted in memory before +// either destination is written, so an unsupported schema shape fails the +// command with both outputs untouched. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "go/format" + "os" + "sort" + "strconv" + "strings" + "text/template" + + "github.com/getkin/kin-openapi/openapi3" +) + +// The three envelope member modes the schema declares for one operation. +const ( + modeForbidden = "forbidden" + modeOptional = "optional" + modeRequired = "required" +) + +// jsonMediaType and sseMediaType are the two success content types the +// generator understands; every other declared success content is refused. +const ( + jsonMediaType = "application/json" + sseMediaType = "text/event-stream" +) + +// noContentStatus is the one success status whose response carries no body. +const noContentStatus = 204 + +// queryMember is one declared query parameter: its name, whether the +// operation requires it, and its declared primitive type. +type queryMember struct { + name string + required bool + kind string +} + +// operation is one schema-declared native operation in its generated form. +type operation struct { + id string + session string + field string + fieldType string + query string + body string + queryMembers []queryMember + status int +} + +// main parses the three explicit paths and runs the generator. +func main() { + schema := flag.String("schema", "", "path to the protocol OpenAPI schema") + out := flag.String("out", "", "path of the generated Go operation bindings") + nativeContractOut := flag.String("native-contract-out", "", "path of the generated native invocation contract") + flag.Parse() + if err := run(*schema, *out, *nativeContractOut); err != nil { + fmt.Fprintln(os.Stderr, "protocolgen:", err) + os.Exit(1) + } +} + +// run writes the two artifacts generated from one schema. A schema or shape +// failure writes neither destination; an ordinary destination I/O failure +// fails the command after the other destination may already exist. +func run(schemaPath, outPath, contractPath string) error { + source, contract, err := generateFiles(schemaPath) + if err != nil { + return err + } + if err := os.WriteFile(outPath, source, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outPath, err) + } + if err := os.WriteFile(contractPath, contract, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", contractPath, err) + } + return nil +} + +// generateFiles loads the schema and returns both artifacts, fully formatted, +// without touching either destination. +func generateFiles(schemaPath string) ([]byte, []byte, error) { + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile(schemaPath) + if err != nil { + return nil, nil, fmt.Errorf("loading %s: %w", schemaPath, err) + } + if err := doc.Validate(context.Background()); err != nil { + return nil, nil, fmt.Errorf("validating %s: %w", schemaPath, err) + } + operations, err := collectOperations(doc) + if err != nil { + return nil, nil, err + } + source, err := renderSource(operations) + if err != nil { + return nil, nil, err + } + contract, err := renderContract(operations) + if err != nil { + return nil, nil, err + } + return source, contract, nil +} + +// collectOperations derives every native operation from the validated schema: +// its addressing, its declared query members, its request body and its one +// success status. The one declared event stream is excluded — exactly one must +// exist — and any shape the envelope cannot address fails the command. Duplicate +// operation IDs are already rejected by the mandatory schema validation before +// this pass runs, so no second identity check is kept here. +func collectOperations(doc *openapi3.T) ([]operation, error) { + paths := doc.Paths.Map() + var ( + operations []operation + streamed []string + ) + for _, pathKey := range sortedKeys(paths) { + item := paths[pathKey] + methods := item.Operations() + for _, method := range sortedKeys(methods) { + declared := methods[method] + if !generatedName(declared.OperationID) { + return nil, fmt.Errorf("%s %s: operationId %q is not a generated Go name", strings.ToUpper(method), pathKey, declared.OperationID) + } + status, stream, err := successResponse(declared) + if err != nil { + return nil, fmt.Errorf("operation %s: %w", declared.OperationID, err) + } + if stream { + streamed = append(streamed, declared.OperationID) + continue + } + op, err := nativeOperation(doc, item, declared, status) + if err != nil { + return nil, fmt.Errorf("operation %s: %w", declared.OperationID, err) + } + operations = append(operations, op) + } + } + if len(streamed) != 1 { + return nil, fmt.Errorf("declares %d event streams, want exactly one", len(streamed)) + } + sort.Slice(operations, func(i, j int) bool { return operations[i].id < operations[j].id }) + return operations, nil +} + +// successResponse reports one operation's single exact numeric 2xx status and +// whether its success content is the event stream. A declared 2XX range, an +// absent or multiple numeric success, a 204 that declares content, a success +// content set that is not exactly one of the two declared media types, and a +// non-204 success without content are all refused; every non-success +// alternative, including the default error response, is ignored. +func successResponse(declared *openapi3.Operation) (int, bool, error) { + responses := declared.Responses.Map() + var statuses []string + for key := range responses { + if !strings.HasPrefix(key, "2") { + continue + } + if len(key) != 3 || !isDigits(key) { + return 0, false, fmt.Errorf("success response %q is not one exact numeric status", key) + } + statuses = append(statuses, key) + } + sort.Strings(statuses) + if len(statuses) != 1 { + return 0, false, fmt.Errorf("declares %d success statuses, want exactly one", len(statuses)) + } + status, err := strconv.Atoi(statuses[0]) + if err != nil { + return 0, false, fmt.Errorf("success status %q is not a number", statuses[0]) + } + response := responses[statuses[0]] + if response.Value == nil { + return 0, false, fmt.Errorf("success status %d is unresolved", status) + } + content := response.Value.Content + // A 204 success is the empty success the retained response owner reads as + // no body at all: it must declare no content, before any media + // classification. + if status == noContentStatus { + if len(content) != 0 { + return 0, false, fmt.Errorf("success status %d declares media types %v, want no content", status, sortedKeys(content)) + } + return status, false, nil + } + switch { + case len(content) == 1 && content[jsonMediaType] != nil: + return status, false, nil + case len(content) == 1 && content[sseMediaType] != nil: + return status, true, nil + case len(content) == 0: + return 0, false, fmt.Errorf("success status %d declares no content", status) + default: + return 0, false, fmt.Errorf("success status %d declares media types %v, want exactly one of %s or %s", status, sortedKeys(content), jsonMediaType, sseMediaType) + } +} + +// isDigits reports whether every byte of one string is an ASCII decimal digit. +func isDigits(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] < '0' || value[index] > '9' { + return false + } + } + return true +} + +// nativeOperation derives one native operation's addressing from its declared +// path parameters, query parameters and request body. +func nativeOperation(doc *openapi3.T, item *openapi3.PathItem, declared *openapi3.Operation, status int) (operation, error) { + op := operation{ + id: declared.OperationID, + status: status, + session: modeForbidden, + field: modeForbidden, + query: modeForbidden, + body: modeForbidden, + } + var pathParameters int + for _, parameter := range append(append(openapi3.Parameters{}, item.Parameters...), declared.Parameters...) { + value := parameter.Value + if value == nil { + return operation{}, fmt.Errorf("parameter is unresolved") + } + switch value.In { + case openapi3.ParameterInPath: + pathParameters++ + if pathParameters > 1 { + return operation{}, fmt.Errorf("declares %d path parameters, want at most one", pathParameters) + } + if value.Schema == nil || value.Schema.Value == nil { + return operation{}, fmt.Errorf("path parameter %q has no schema", value.Name) + } + if ref := value.Schema.Ref; ref != "" { + name, err := enumTypeName(doc, ref) + if err != nil { + return operation{}, fmt.Errorf("path parameter %q: %w", value.Name, err) + } + op.field = modeRequired + op.fieldType = name + continue + } + if value.Name != "id" || !isStringSchema(value.Schema.Value) { + return operation{}, fmt.Errorf("path parameter %q has no supported addressing", value.Name) + } + op.session = modeRequired + case openapi3.ParameterInQuery: + member, err := queryParameter(value) + if err != nil { + return operation{}, err + } + for _, existing := range op.queryMembers { + if existing.name == member.name { + return operation{}, fmt.Errorf("query parameter %q is declared twice", member.name) + } + } + op.queryMembers = append(op.queryMembers, member) + default: + return operation{}, fmt.Errorf("parameter %q is declared in unsupported location %q", value.Name, value.In) + } + } + op.query = modeForbidden + if len(op.queryMembers) > 0 { + op.query = modeOptional + for _, member := range op.queryMembers { + if member.required { + op.query = modeRequired + } + } + } + if declared.RequestBody != nil && declared.RequestBody.Value != nil { + content := declared.RequestBody.Value.Content + if len(content) != 1 || content[jsonMediaType] == nil { + return operation{}, fmt.Errorf("request body declares media types %v, want exactly %s", sortedKeys(content), jsonMediaType) + } + schema := content[jsonMediaType].Schema + if schema == nil || schema.Value == nil { + return operation{}, fmt.Errorf("request body schema is unresolved") + } + if schema.Value.Type == nil || !schema.Value.Type.Is("object") { + return operation{}, fmt.Errorf("request body schema is not one explicit object") + } + if !declared.RequestBody.Value.Required { + return operation{}, fmt.Errorf("request body is optional, want required") + } + op.body = modeRequired + } + return op, nil +} + +// enumTypeName resolves one referenced path-parameter schema to the generated +// protocol type the binder casts the envelope's field member to: an internal +// component reference to a resolved string schema with a nonempty all-string +// enum and a component name the generated client uses unchanged. +func enumTypeName(doc *openapi3.T, ref string) (string, error) { + const prefix = "#/components/schemas/" + if !strings.HasPrefix(ref, prefix) { + return "", fmt.Errorf("reference %q is not an internal #/components/schemas/ reference", ref) + } + name := strings.TrimPrefix(ref, prefix) + if !generatedName(name) { + return "", fmt.Errorf("referenced component name %q is not a generated Go name", name) + } + component := doc.Components.Schemas[name] + if component == nil || component.Value == nil { + return "", fmt.Errorf("referenced component %q is unresolved", name) + } + schema := component.Value + if !isStringSchema(schema) { + return "", fmt.Errorf("referenced component %q is not the string type", name) + } + if len(schema.Enum) == 0 { + return "", fmt.Errorf("referenced component %q declares no enum values", name) + } + for _, value := range schema.Enum { + if _, ok := value.(string); !ok { + return "", fmt.Errorf("referenced component %q declares the non-string enum value %v", name, value) + } + } + return name, nil +} + +// generatedName reports whether one schema identifier is already the exact +// generated Go name the pinned generator uses: nonempty, an ASCII uppercase +// initial and only ASCII letters and digits after it. An identifier that would +// need normalization — a lowercase initial, an underscore or other separator, +// a leading digit — is refused explicitly instead of being silently mapped or +// left to fail as generated Go. +func generatedName(name string) bool { + if name == "" || name[0] < 'A' || name[0] > 'Z' { + return false + } + for index := 1; index < len(name); index++ { + character := name[index] + switch { + case character >= 'A' && character <= 'Z', + character >= 'a' && character <= 'z', + character >= '0' && character <= '9': + default: + return false + } + } + return true +} + +// queryParameter resolves one declared query parameter's primitive type: only +// the string and boolean primitives the generated Params types bind are +// supported. +func queryParameter(value *openapi3.Parameter) (queryMember, error) { + if value.Schema == nil || value.Schema.Value == nil { + return queryMember{}, fmt.Errorf("query parameter %q has no schema", value.Name) + } + schema := value.Schema.Value + switch { + case isStringSchema(schema): + return queryMember{name: value.Name, required: value.Required, kind: "string"}, nil + case schema.Type != nil && schema.Type.Is("boolean"): + return queryMember{name: value.Name, required: value.Required, kind: "boolean"}, nil + default: + return queryMember{}, fmt.Errorf("query parameter %q has an unsupported primitive type", value.Name) + } +} + +// isStringSchema reports whether one schema is the string primitive. +func isStringSchema(schema *openapi3.Schema) bool { + return schema.Type != nil && schema.Type.Is("string") +} + +// sortedKeys returns one string-keyed map's keys in sorted order, so every +// derived artifact is deterministic. +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// sourceTemplate renders the generated Go bindings. go/format normalizes the +// result, so the template only has to emit valid, complete Go. +const sourceTemplate = `// Code generated by internal/protocolgen from protocol/openapi.yaml; DO NOT EDIT. +// +// The native operation table and its dispatch are derived from the schema: +// operationNames is the sorted set of declared native operations and +// invokeGenerated binds each one's declared addressing before calling the +// matching generated client method. +package clientops + +import ( +{{- if .NeedsBytes}} + "bytes" +{{- end}} + "context" + "encoding/json" + "net/http" + + "github.com/MMinasyan/lightcode/client" +{{- if .NeedsProtocol}} + "github.com/MMinasyan/lightcode/protocol" +{{- end}} +) + +// operationNames is the schema's native operation set in sorted order: every +// declared operation whose success response is not the event stream. It is the +// sole authority for the initialize advertisement and for Call's own admission. +var operationNames = []string{ +{{- range .Names}} + {{printf "%q" .}}, +{{- end}} +} + +// invokeGenerated binds one invocation envelope to the generated client +// method the named operation declares and returns that call's raw response +// beside the operation's declared success status. A local binding failure or a +// failure to obtain an HTTP response returns a nil response and the zero +// status; the caller owns response interpretation. +func invokeGenerated(ctx context.Context, c *client.Client, operation string, params json.RawMessage) (*http.Response, int, error) { + switch operation { +{{- range .Arms}} +{{template "arm" .}} +{{- end}} + default: + return nil, 0, invalidParams("unknown operation %q", operation) + } +} +{{define "arm"}} case {{printf "%q" .ID}}: +{{- if .NeedEnv}} + env, err := bindEnvelope({{.Envelope}}) + if err != nil { + return nil, 0, err + } +{{- else}} + if _, err := bindEnvelope({{.Envelope}}); err != nil { + return nil, 0, err + } +{{- end}} +{{- if .QueryType}} + var query protocol.{{.QueryType}} +{{- if .QueryOptional}} + if env.query != nil { + if err := bindQuery(env.query, nil, &query); err != nil { + return nil, 0, err + } + } +{{- else}} + if err := bindQuery(env.query, {{.QueryRequired}}, &query); err != nil { + return nil, 0, err + } +{{- end}} +{{- end}} + response, callErr := {{.Call}} + return response, {{.Expected}}, callErr +{{end}}` + +// armView is one generated dispatch arm. +type armView struct { + ID string + Envelope string + NeedEnv bool + QueryType string + QueryRequired string + QueryOptional bool + Call string + Expected string +} + +// sourceData is the whole generated file's template data. +type sourceData struct { + Names []string + Arms []armView + NeedsBytes bool + NeedsProtocol bool +} + +// renderSource renders and formats the generated Go bindings in memory. +func renderSource(operations []operation) ([]byte, error) { + data := sourceData{} + for _, op := range operations { + data.Names = append(data.Names, op.id) + data.Arms = append(data.Arms, arm(op)) + if op.body != modeForbidden { + data.NeedsBytes = true + } + if op.session != modeForbidden || op.field != modeForbidden || op.query != modeForbidden { + data.NeedsProtocol = true + } + } + parsed, err := template.New("source").Parse(sourceTemplate) + if err != nil { + return nil, err + } + var rendered strings.Builder + if err := parsed.Execute(&rendered, data); err != nil { + return nil, err + } + formatted, err := format.Source([]byte(rendered.String())) + if err != nil { + return nil, fmt.Errorf("formatting the generated bindings: %w", err) + } + return formatted, nil +} + +// arm derives one dispatch arm: the envelope's declared member rules, the +// query binding and the exact generated client call for the operation. The +// declared success status is emitted as the schema's own number, so any single +// numeric 2xx is supported without a named standard-library constant. +func arm(op operation) armView { + view := armView{ + ID: op.id, + Envelope: fmt.Sprintf("params, %s, %s, %s, %s", + goMode(op.session), goMode(op.field), goMode(op.query), goMode(op.body)), + Expected: strconv.Itoa(op.status), + } + view.NeedEnv = op.session != modeForbidden || op.field != modeForbidden || op.query != modeForbidden || op.body != modeForbidden + if op.query != modeForbidden { + view.QueryType = op.id + "Params" + view.QueryOptional = op.query == modeOptional + var required []string + for _, member := range op.queryMembers { + if member.required { + required = append(required, strconv.Quote(member.name)) + } + } + if len(required) == 0 { + view.QueryRequired = "nil" + } else { + view.QueryRequired = "[]string{" + strings.Join(required, ", ") + "}" + } + } + arguments := []string{"ctx"} + if op.session != modeForbidden { + arguments = append(arguments, "protocol.SessionID(env.session)") + } + if op.fieldType != "" { + arguments = append(arguments, "protocol."+op.fieldType+"(env.field)") + } + if op.query != modeForbidden { + arguments = append(arguments, "&query") + } + name := "c." + op.id + if op.body != modeForbidden { + name += "WithBody" + arguments = append(arguments, "contentTypeJSON", "bytes.NewReader(env.body)") + } + view.Call = name + "(" + strings.Join(arguments, ", ") + ")" + return view +} + +// goMode maps one declared envelope mode to the handwritten member rule +// constant the generated bindings use. +func goMode(mode string) string { + switch mode { + case modeRequired: + return "memberRequired" + case modeOptional: + return "memberOptional" + default: + return "memberForbidden" + } +} + +// contractMember is one query member's published contract. +type contractMember struct { + Required bool `json:"required"` + Type string `json:"type"` +} + +// contractOperation is one operation's published contract. +type contractOperation struct { + SessionID string `json:"session_id"` + Field string `json:"field"` + Query string `json:"query"` + Body string `json:"body"` + QueryMembers map[string]contractMember `json:"query_members"` +} + +// renderContract renders the test-only native invocation contract in memory: +// one entry per native operation, keyed by operation ID, with sorted keys and +// no stream entry. +func renderContract(operations []operation) ([]byte, error) { + doc := make(map[string]contractOperation, len(operations)) + for _, op := range operations { + members := make(map[string]contractMember, len(op.queryMembers)) + for _, member := range op.queryMembers { + members[member.name] = contractMember{Required: member.required, Type: member.kind} + } + doc[op.id] = contractOperation{ + SessionID: op.session, + Field: op.field, + Query: op.query, + Body: op.body, + QueryMembers: members, + } + } + rendered, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, fmt.Errorf("rendering the native invocation contract: %w", err) + } + return append(rendered, '\n'), nil +} diff --git a/internal/protocolgen/main_test.go b/internal/protocolgen/main_test.go new file mode 100644 index 00000000..1e349462 --- /dev/null +++ b/internal/protocolgen/main_test.go @@ -0,0 +1,943 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// The fixture schema is a minimal valid protocol: one JSON operation, one +// optional-query operation and the one declared event stream. Each case below +// composes the paths it needs, so a supported addition and every unsupported +// shape are exercised against a schema that would otherwise generate. +const ( + fixtureHead = `openapi: "3.0.3" +info: + title: protocolgen-fixture + version: "1" +paths: +` + healthPath = ` /v1/health: + get: + operationId: GetHealth + responses: + "200": + description: readiness + content: + application/json: + schema: + type: object +` + eventsPath = ` /v1/events: + get: + operationId: GetEvents + responses: + "200": + description: the one event stream + content: + text/event-stream: + schema: + type: object +` +) + +// writeFixture writes one schema text and returns its path. +func writeFixture(t *testing.T, text string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "openapi.yaml") + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + t.Fatalf("writing fixture schema: %v", err) + } + return path +} + +// generateFixture runs the generator over one schema text and returns both +// artifacts. +func generateFixture(t *testing.T, text string) (string, string) { + t.Helper() + source, contract, err := generateFiles(writeFixture(t, text)) + if err != nil { + t.Fatalf("generating from the fixture schema: %v", err) + } + return string(source), string(contract) +} + +// TestSupportedAdditionGenerates proves a supported schema addition generates +// into both artifacts deterministically: the added operation appears in the +// sorted names, in its dispatch arm and in the invocation contract, and two +// runs over the same schema are byte-identical. +func TestSupportedAdditionGenerates(t *testing.T) { + const addition = ` /v1/sessions/{id}/extra: + post: + operationId: Extra + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: the added operation + content: + application/json: + schema: + type: object +` + schema := fixtureHead + healthPath + eventsPath + addition + source, contract := generateFixture(t, schema) + if !strings.Contains(source, `"Extra",`) { + t.Errorf("generated bindings do not advertise the added operation:\n%s", source) + } + if !strings.Contains(source, `case "Extra":`) { + t.Errorf("generated bindings have no dispatch arm for the added operation") + } + if !strings.Contains(source, "c.ExtraWithBody(ctx, protocol.SessionID(env.session), contentTypeJSON, bytes.NewReader(env.body))") { + t.Errorf("generated arm does not call the added operation's generated method") + } + if !strings.Contains(source, "return response, 201, callErr") { + t.Errorf("generated arm does not carry the added operation's declared success status") + } + if !strings.Contains(contract, `"Extra": {`) { + t.Errorf("invocation contract has no entry for the added operation:\n%s", contract) + } + if !strings.Contains(contract, `"body": "required"`) || !strings.Contains(contract, `"session_id": "required"`) { + t.Errorf("invocation contract does not carry the added operation's declared modes") + } + if strings.Contains(contract, "GetEvents") { + t.Errorf("invocation contract carries the event stream:\n%s", contract) + } + againSource, againContract := generateFixture(t, schema) + if source != againSource || contract != againContract { + t.Error("two runs over the same schema produced different artifacts") + } +} + +// TestOptionalQueryMembersGenerate proves the optional query envelope modes +// derive from the schema: a query mixing required and optional members binds +// every member and requires the required ones, and an optional-only query +// binds only when present through the nil-required guard. The contract +// publishes both forms and neither operation declares a request body. +func TestOptionalQueryMembersGenerate(t *testing.T) { + const mixedQuery = ` /v1/optional: + post: + operationId: Optional + parameters: + - name: cursor + in: query + required: false + schema: + type: string + - name: all + in: query + required: true + schema: + type: boolean + responses: + "200": + description: the mixed query operation + content: + application/json: + schema: + type: object +` + const optionalQuery = ` /v1/sessions/{id}/history: + get: + operationId: GetSessionHistory + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: cursor + in: query + required: false + schema: + type: string + responses: + "200": + description: the optional query operation + content: + application/json: + schema: + type: object +` + for _, tc := range []struct { + name string + path string + source []string + contract []string + }{ + { + name: "required and optional query members without a body", + path: mixedQuery, + source: []string{ + `bindEnvelope(params, memberForbidden, memberForbidden, memberRequired, memberForbidden)`, + `bindQuery(env.query, []string{"all"}, &query)`, + `c.Optional(ctx, &query)`, + }, + contract: []string{ + `"Optional": { + "session_id": "forbidden", + "field": "forbidden", + "query": "required", + "body": "forbidden"`, + `"cursor": { + "required": false, + "type": "string" + }`, + `"all": { + "required": true, + "type": "boolean" + }`, + }, + }, + { + name: "an optional-only query binds only when present", + path: optionalQuery, + source: []string{ + `bindEnvelope(params, memberRequired, memberForbidden, memberOptional, memberForbidden)`, + `if env.query != nil {`, + `bindQuery(env.query, nil, &query)`, + `c.GetSessionHistory(ctx, protocol.SessionID(env.session), &query)`, + }, + contract: []string{ + `"GetSessionHistory": { + "session_id": "required", + "field": "forbidden", + "query": "optional", + "body": "forbidden"`, + `"cursor": { + "required": false, + "type": "string" + }`, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + source, contract := generateFixture(t, fixtureHead+healthPath+eventsPath+tc.path) + for _, want := range tc.source { + if !strings.Contains(source, want) { + t.Errorf("generated bindings are missing %q:\n%s", want, source) + } + } + for _, want := range tc.contract { + if !strings.Contains(contract, want) { + t.Errorf("invocation contract is missing %q:\n%s", want, contract) + } + } + }) + } +} + +// TestSupportedShapesGenerate pins the accepted shapes the extraction pass +// must keep: a referenced string-enum field path emits its generated protocol +// type and call with the declared metadata, and a single numeric 2xx — +// including one with no named standard-library constant — is emitted as the +// schema's own number, beside the unchanged contentless 204 arm. +func TestSupportedShapesGenerate(t *testing.T) { + const fieldsPath = ` /v1/fields/{field}: + delete: + operationId: ResetField + parameters: + - name: field + in: path + required: true + schema: + $ref: "#/components/schemas/ProviderField" + - name: provider_id + in: query + required: true + schema: + type: string + responses: + "200": + description: the reset post-state + content: + application/json: + schema: + type: object +` + const acceptedPath = ` /v1/accepted: + post: + operationId: Accepted203 + responses: + "203": + description: an unlisted numeric success + content: + application/json: + schema: + type: object +` + const extendedPath = ` /v1/extended: + get: + operationId: Extended210 + responses: + "210": + description: another unlisted numeric success + content: + application/json: + schema: + type: object +` + const removedPath = ` /v1/removed: + delete: + operationId: Removed204 + responses: + "204": + description: a contentless success +` + const components = `components: + schemas: + ProviderField: + type: string + enum: + - name + - hidden +` + source, contract := generateFixture(t, fixtureHead+healthPath+eventsPath+fieldsPath+acceptedPath+extendedPath+removedPath+components) + for _, want := range []string{ + `case "ResetField":`, + `bindEnvelope(params, memberForbidden, memberRequired, memberRequired, memberForbidden)`, + `bindQuery(env.query, []string{"provider_id"}, &query)`, + `c.ResetField(ctx, protocol.ProviderField(env.field), &query)`, + `return response, 203, callErr`, + `return response, 210, callErr`, + `return response, 204, callErr`, + `"Accepted203",`, + `"Extended210",`, + `"Removed204",`, + `"ResetField",`, + } { + if !strings.Contains(source, want) { + t.Errorf("generated bindings are missing %q:\n%s", want, source) + } + } + if strings.Contains(source, "http.Status") { + t.Errorf("generated bindings carry a named status constant instead of the schema's number:\n%s", source) + } + for _, want := range []string{ + `"ResetField": {`, + `"field": "required"`, + `"Accepted203": {`, + `"Extended210": {`, + `"Removed204": {`, + `"provider_id": { + "required": true, + "type": "string" + }`, + } { + if !strings.Contains(contract, want) { + t.Errorf("invocation contract is missing %q:\n%s", want, contract) + } + } +} + +// TestUnsupportedShapesWriteNothing proves every unsupported schema shape +// fails the command before either destination is touched: both pre-existing +// artifacts keep their exact bytes. +func TestUnsupportedShapesWriteNothing(t *testing.T) { + base := fixtureHead + healthPath + eventsPath + cases := []struct { + name string + schema string + }{ + { + name: "duplicate operationId", + schema: base + ` /v1/other: + get: + operationId: GetHealth + responses: + "200": + description: a duplicate id + content: + application/json: + schema: + type: object +`, + }, + { + name: "missing operationId", + schema: base + ` /v1/other: + get: + responses: + "200": + description: an anonymous operation + content: + application/json: + schema: + type: object +`, + }, + { + name: "two success statuses", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: one success + content: + application/json: + schema: + type: object + "201": + description: another success + content: + application/json: + schema: + type: object +`, + }, + { + name: "a success status without content", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: a contentless success +`, + }, + { + name: "non-JSON success content", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: a text success + content: + text/plain: + schema: + type: string +`, + }, + { + name: "mixed JSON and stream content", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: a mixed success + content: + application/json: + schema: + type: object + text/event-stream: + schema: + type: object +`, + }, + { + name: "no event stream", + schema: fixtureHead + healthPath, + }, + { + name: "a second event stream", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: a second stream + content: + text/event-stream: + schema: + type: object +`, + }, + { + name: "an unsupported query primitive", + schema: base + ` /v1/other: + get: + operationId: Other + parameters: + - name: limit + in: query + required: true + schema: + type: integer + responses: + "200": + description: a numeric query + content: + application/json: + schema: + type: object +`, + }, + { + name: "two path parameters", + schema: base + ` /v1/other/{a}/{b}: + get: + operationId: Other + parameters: + - name: a + in: path + required: true + schema: + type: string + - name: b + in: path + required: true + schema: + type: string + responses: + "200": + description: two addresses + content: + application/json: + schema: + type: object +`, + }, + { + name: "an unaddressable path parameter", + schema: base + ` /v1/other/{name}: + get: + operationId: Other + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: an unnamed address + content: + application/json: + schema: + type: object +`, + }, + { + name: "a request body without JSON content", + schema: base + ` /v1/other: + post: + operationId: Other + requestBody: + required: true + content: + text/plain: + schema: + type: string + responses: + "200": + description: a text body + content: + application/json: + schema: + type: object +`, + }, + { + name: "an optional request body", + schema: base + ` /v1/other: + post: + operationId: Other + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + "200": + description: an optional body + content: + application/json: + schema: + type: object +`, + }, + { + name: "a 204 success with JSON content", + schema: base + ` /v1/other: + delete: + operationId: Other + responses: + "204": + description: a bodyless success carrying JSON + content: + application/json: + schema: + type: object +`, + }, + { + name: "a 204 success with SSE content", + schema: fixtureHead + healthPath + ` /v1/other: + delete: + operationId: Other + responses: + "204": + description: a bodyless success carrying a stream + content: + text/event-stream: + schema: + type: object +`, + }, + { + name: "a patterned 2XX success alone", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "2XX": + description: a patterned success + content: + application/json: + schema: + type: object +`, + }, + { + name: "a patterned 2XX success beside 200", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: a numeric success + content: + application/json: + schema: + type: object + "2XX": + description: a patterned success + content: + application/json: + schema: + type: object +`, + }, + { + name: "JSON and plain success media", + schema: base + ` /v1/other: + get: + operationId: Other + responses: + "200": + description: a two-media success + content: + application/json: + schema: + type: object + text/plain: + schema: + type: string +`, + }, + { + name: "SSE and plain success media", + schema: fixtureHead + healthPath + ` /v1/stream: + get: + operationId: Streamed + responses: + "200": + description: a two-media stream + content: + text/event-stream: + schema: + type: object + text/plain: + schema: + type: string +`, + }, + { + name: "a referenced integer field path", + schema: base + ` /v1/fields/{field}: + delete: + operationId: ResetField + parameters: + - name: field + in: path + required: true + schema: + $ref: "#/components/schemas/FieldCount" + responses: + "200": + description: the reset post-state + content: + application/json: + schema: + type: object +components: + schemas: + FieldCount: + type: integer +`, + }, + { + name: "a referenced non-enum string field path", + schema: base + ` /v1/fields/{field}: + delete: + operationId: ResetField + parameters: + - name: field + in: path + required: true + schema: + $ref: "#/components/schemas/FieldName" + responses: + "200": + description: the reset post-state + content: + application/json: + schema: + type: object +components: + schemas: + FieldName: + type: string +`, + }, + { + name: "a referenced mixed-enum field path", + schema: base + ` /v1/fields/{field}: + delete: + operationId: ResetField + parameters: + - name: field + in: path + required: true + schema: + $ref: "#/components/schemas/FieldName" + responses: + "200": + description: the reset post-state + content: + application/json: + schema: + type: object +components: + schemas: + FieldName: + type: string + enum: + - name + - 1 +`, + }, + { + name: "a referenced external field path", + schema: base + ` /v1/fields/{field}: + delete: + operationId: ResetField + parameters: + - name: field + in: path + required: true + schema: + $ref: "https://example.invalid/field.yaml#/FieldName" + responses: + "200": + description: the reset post-state + content: + application/json: + schema: + type: object +`, + }, + { + name: "a noncanonical lowercase operation name", + schema: base + ` /v1/other: + get: + operationId: get_extra + responses: + "200": + description: a normalized name + content: + application/json: + schema: + type: object +`, + }, + { + name: "a noncanonical separated operation name", + schema: base + ` /v1/other: + get: + operationId: Get_extra + responses: + "200": + description: a normalized name + content: + application/json: + schema: + type: object +`, + }, + { + name: "a leading-digit operation name", + schema: base + ` /v1/other: + get: + operationId: 9Lives + responses: + "200": + description: a normalized name + content: + application/json: + schema: + type: object +`, + }, + { + name: "a noncanonical referenced type name", + schema: base + ` /v1/fields/{field}: + delete: + operationId: ResetField + parameters: + - name: field + in: path + required: true + schema: + $ref: "#/components/schemas/provider_field" + responses: + "200": + description: the reset post-state + content: + application/json: + schema: + type: object +components: + schemas: + provider_field: + type: string + enum: + - name +`, + }, + { + name: "an array request body", + schema: base + ` /v1/other: + post: + operationId: Other + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: string + responses: + "200": + description: an array body + content: + application/json: + schema: + type: object +`, + }, + { + name: "a string request body", + schema: base + ` /v1/other: + post: + operationId: Other + requestBody: + required: true + content: + application/json: + schema: + type: string + responses: + "200": + description: a string body + content: + application/json: + schema: + type: object +`, + }, + { + name: "an untyped request body", + schema: base + ` /v1/other: + post: + operationId: Other + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + responses: + "200": + description: an untyped body + content: + application/json: + schema: + type: object +`, + }, + { + name: "a two-media request body", + schema: base + ` /v1/other: + post: + operationId: Other + requestBody: + required: true + content: + application/json: + schema: + type: object + text/plain: + schema: + type: string + responses: + "200": + description: a two-media body + content: + application/json: + schema: + type: object +`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "openapi.yaml") + if err := os.WriteFile(schemaPath, []byte(tc.schema), 0o644); err != nil { + t.Fatalf("writing the fixture schema: %v", err) + } + outPath := filepath.Join(dir, "operations.gen.go") + contractPath := filepath.Join(dir, "native-invocations.json") + const sentinel = "untouched\n" + for _, path := range []string{outPath, contractPath} { + if err := os.WriteFile(path, []byte(sentinel), 0o644); err != nil { + t.Fatalf("writing the sentinel artifact: %v", err) + } + } + if err := run(schemaPath, outPath, contractPath); err == nil { + t.Fatal("the generator accepted an unsupported schema shape") + } else { + t.Logf("rejected: %v", err) + } + for _, path := range []string{outPath, contractPath} { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + if !bytes.Equal(content, []byte(sentinel)) { + t.Fatalf("unsupported input rewrote %s: %q", filepath.Base(path), content) + } + } + }) + } +} diff --git a/protocol/contracts_test.go b/protocol/contracts_test.go index 54bf2914..7e6a9ef5 100644 --- a/protocol/contracts_test.go +++ b/protocol/contracts_test.go @@ -203,8 +203,8 @@ func TestProviderAndModelFieldSetsShareEditAndResetPaths(t *testing.T) { wantEnum []string }{ {"/v1/providers/fields/{field}", "ProviderField", []string{ - "name", "base_url", "api_key_env", "headers", "options", "system_role", - "usage_in_stream", "max_tokens_field", "extra_body", "discovery", "protocol_metadata", "hidden", + "name", "base_url", "api_key_env", "headers", "system_role", + "usage_in_stream", "extra_body", "discovery", "protocol_metadata", "hidden", }}, {"/v1/providers/models/fields/{field}", "ModelField", []string{ "name", "context_window", "max_output_tokens", "input_modalities", "system_role", diff --git a/protocol/freshness_test.go b/protocol/freshness_test.go index 4536604e..dc976414 100644 --- a/protocol/freshness_test.go +++ b/protocol/freshness_test.go @@ -6,7 +6,11 @@ import ( "os" "os/exec" "path/filepath" + "slices" + "strings" "testing" + + "gopkg.in/yaml.v3" ) // TestGeneratedContractsFresh regenerates the Go protocol artifact from the @@ -40,23 +44,103 @@ func TestGeneratedContractsFresh(t *testing.T) { } } +// TestGeneratedNativeBindingsFresh regenerates the native operation bindings +// and the test-only native invocation contract from the committed schema with +// the committed build command and compares both byte-for-byte against the +// committed artifacts. Two runs must agree, so a hand edit, a stale artifact +// or a nondeterministic generator fails here. The TypeScript artifact is not +// covered here — its regeneration needs the frontend toolchain and is gated by +// the make check-protocol target. +func TestGeneratedNativeBindingsFresh(t *testing.T) { + committedSource := committedArtifact(t, "internal/clientops/operations.gen.go") + committedContract := committedArtifact(t, "frontend/src/generated/native-invocations.json") + generate := func(t *testing.T) ([]byte, []byte) { + t.Helper() + dir := t.TempDir() + source := filepath.Join(dir, "operations.gen.go") + contract := filepath.Join(dir, "native-invocations.json") + cmd := exec.Command("go", "run", "./internal/protocolgen", + "-schema", "protocol/openapi.yaml", + "-out", source, + "-native-contract-out", contract) + cmd.Dir = ".." + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("regenerating native bindings: %v\n%s", err, out) + } + return readArtifact(t, source), readArtifact(t, contract) + } + source, contract := generate(t) + againSource, againContract := generate(t) + if !bytes.Equal(source, againSource) || !bytes.Equal(contract, againContract) { + t.Fatal("the native bindings generator is not deterministic") + } + if !bytes.Equal(committedSource, source) { + t.Fatalf("committed internal/clientops/operations.gen.go is stale: regenerate with make generate-protocol (%d committed bytes, %d regenerated)", len(committedSource), len(source)) + } + if !bytes.Equal(committedContract, contract) { + t.Fatalf("committed frontend/src/generated/native-invocations.json is stale: regenerate with make generate-protocol (%d committed bytes, %d regenerated)", len(committedContract), len(contract)) + } +} + +// committedArtifact reads one committed artifact relative to the module root. +func committedArtifact(t *testing.T, rel string) []byte { + t.Helper() + return readArtifact(t, filepath.Join("..", filepath.FromSlash(rel))) +} + +// readArtifact reads one artifact or fails the calling test. +func readArtifact(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + return data +} + // TestGeneratedFileSetGate exercises the shared freshness shell check in a -// disposable Git fixture: staged tracked bytes matching the fixture state -// pass, modified tracked output fails, a newly emitted untracked artifact -// fails, and an unrelated untracked file does not. The fixture never +// disposable Git fixture with explicit path arguments: staged tracked bytes +// matching the fixture state pass, modified or deleted tracked output fails, +// a newly emitted or unstaged artifact fails, ignored output under the +// supplied paths and unrelated untracked output do not. The fixture never // commits and never touches Git configuration; the only Git writes anywhere -// in this test are init and add inside that disposable fixture. +// in this test are init, add and one unstaging rm inside that disposable +// fixture. func TestGeneratedFileSetGate(t *testing.T) { - script, err := filepath.Abs(filepath.Join("..", "scripts", "check-protocol-generated.sh")) + script, err := filepath.Abs(filepath.Join("..", "scripts", "check-generated-files.sh")) if err != nil { t.Fatalf("resolving the freshness script: %v", err) } + // The two real invocations: make check-protocol names the four protocol + // artifacts (directories included) and make check-wails-bindings names the + // generated Wails bindings directory. + protocolPaths := []string{ + "protocol/protocol.gen.go", + "frontend/src/generated/protocol", + "internal/clientops/operations.gen.go", + "frontend/src/generated/native-invocations.json", + } + wailsPaths := []string{"frontend/client-bindings/wailsjs/go"} + + // Every checked output is a tracked file in the fixture, so the matching + // case proves each artifact path passes under its real invocation. + outputs := []struct { + path string + paths []string + }{ + {"protocol/protocol.gen.go", protocolPaths}, + {"frontend/src/generated/protocol/types.gen.ts", protocolPaths}, + {"internal/clientops/operations.gen.go", protocolPaths}, + {"frontend/src/generated/native-invocations.json", protocolPaths}, + {"frontend/client-bindings/wailsjs/go/desktopclient/Host.js", wailsPaths}, + } + fixture := func(t *testing.T) string { t.Helper() dir := t.TempDir() - for _, file := range []string{"protocol/protocol.gen.go", "frontend/src/generated/protocol/types.gen.ts"} { - path := filepath.Join(dir, filepath.FromSlash(file)) + for _, output := range outputs { + path := filepath.Join(dir, filepath.FromSlash(output.path)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("creating fixture directory: %v", err) } @@ -64,6 +148,23 @@ func TestGeneratedFileSetGate(t *testing.T) { t.Fatalf("writing fixture artifact: %v", err) } } + // Ignored output the gates must never flag: the ignored Wails runtime + // tree and an ignored sibling inside the checked bindings directory. + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("frontend/client-bindings/wailsjs/runtime/\n*.ignored\n"), 0o644); err != nil { + t.Fatalf("writing fixture ignore file: %v", err) + } + for _, file := range []string{ + "frontend/client-bindings/wailsjs/runtime/runtime.js", + "frontend/client-bindings/wailsjs/go/desktopclient/Host.js.ignored", + } { + path := filepath.Join(dir, filepath.FromSlash(file)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("creating ignored fixture directory: %v", err) + } + if err := os.WriteFile(path, []byte("ignored"), 0o644); err != nil { + t.Fatalf("writing ignored fixture file: %v", err) + } + } if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil { t.Fatalf("fixture git init: %v\n%s", err, out) } @@ -73,9 +174,9 @@ func TestGeneratedFileSetGate(t *testing.T) { return dir } - run := func(t *testing.T, dir string) error { + run := func(t *testing.T, dir string, paths ...string) error { t.Helper() - cmd := exec.Command("sh", script) + cmd := exec.Command("sh", append([]string{script}, paths...)...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("exit: %v\n%s", err, out) @@ -84,41 +185,361 @@ func TestGeneratedFileSetGate(t *testing.T) { } t.Run("matching staged bytes pass", func(t *testing.T) { - if err := run(t, fixture(t)); err != nil { - t.Fatalf("gate rejected matching staged artifacts: %v", err) + dir := fixture(t) + if err := run(t, dir, protocolPaths...); err != nil { + t.Fatalf("gate rejected matching staged protocol artifacts: %v", err) + } + if err := run(t, dir, wailsPaths...); err != nil { + t.Fatalf("gate rejected matching staged Wails bindings: %v", err) + } + }) + + t.Run("no path arguments fail", func(t *testing.T) { + if err := run(t, fixture(t)); err == nil { + t.Fatal("gate accepted an invocation without path arguments") } }) t.Run("modified tracked output fails", func(t *testing.T) { - dir := fixture(t) - path := filepath.Join(dir, "protocol", "protocol.gen.go") - if err := os.WriteFile(path, []byte("hand-edited"), 0o644); err != nil { - t.Fatalf("modifying fixture artifact: %v", err) + for _, output := range outputs { + dir := fixture(t) + path := filepath.Join(dir, filepath.FromSlash(output.path)) + if err := os.WriteFile(path, []byte("hand-edited"), 0o644); err != nil { + t.Fatalf("modifying fixture artifact: %v", err) + } + if err := run(t, dir, output.paths...); err == nil { + t.Fatalf("gate accepted a modified tracked artifact: %s", output.path) + } } - if err := run(t, dir); err == nil { - t.Fatal("gate accepted a modified tracked artifact") + }) + + t.Run("deleted tracked output fails", func(t *testing.T) { + for _, output := range outputs { + dir := fixture(t) + path := filepath.Join(dir, filepath.FromSlash(output.path)) + if err := os.Remove(path); err != nil { + t.Fatalf("deleting fixture artifact: %v", err) + } + if err := run(t, dir, output.paths...); err == nil { + t.Fatalf("gate accepted a deleted tracked artifact: %s", output.path) + } } }) t.Run("new untracked generated file fails", func(t *testing.T) { + for _, output := range outputs { + dir := fixture(t) + // The staged artifact becomes untracked output again. + if out, err := exec.Command("git", "-C", dir, "rm", "--cached", "-q", "--", output.path).CombinedOutput(); err != nil { + t.Fatalf("unstaging fixture artifact %s: %v\n%s", output.path, err, out) + } + if err := run(t, dir, output.paths...); err == nil { + t.Fatalf("gate accepted a newly untracked artifact: %s", output.path) + } + } + for _, output := range []struct { + path string + paths []string + }{ + {"frontend/src/generated/protocol/client.gen.ts", protocolPaths}, + {"frontend/client-bindings/wailsjs/go/desktopclient/New.js", wailsPaths}, + } { + dir := fixture(t) + path := filepath.Join(dir, filepath.FromSlash(output.path)) + if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil { + t.Fatalf("emitting untracked fixture artifact %s: %v", output.path, err) + } + if err := run(t, dir, output.paths...); err == nil { + t.Fatalf("gate accepted a newly emitted untracked artifact: %s", output.path) + } + } + }) + + t.Run("output outside the supplied paths passes", func(t *testing.T) { dir := fixture(t) - path := filepath.Join(dir, "frontend", "src", "generated", "protocol", "client.gen.ts") - if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil { - t.Fatalf("emitting untracked fixture artifact: %v", err) + if err := os.WriteFile(filepath.Join(dir, "protocol", "protocol.gen.go"), []byte("hand-edited"), 0o644); err != nil { + t.Fatalf("modifying fixture artifact: %v", err) } - if err := run(t, dir); err == nil { - t.Fatal("gate accepted a newly emitted untracked artifact") + if err := run(t, dir, wailsPaths...); err != nil { + t.Fatalf("gate flagged output outside its path arguments: %v", err) } }) t.Run("unrelated untracked file passes", func(t *testing.T) { dir := fixture(t) - path := filepath.Join(dir, "unrelated.txt") - if err := os.WriteFile(path, []byte("junk"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "unrelated.txt"), []byte("junk"), 0o644); err != nil { t.Fatalf("writing unrelated fixture file: %v", err) } - if err := run(t, dir); err != nil { + if err := run(t, dir, protocolPaths...); err != nil { t.Fatalf("gate rejected an unrelated untracked file: %v", err) } }) + + t.Run("ignored output passes", func(t *testing.T) { + if err := run(t, fixture(t), wailsPaths...); err != nil { + t.Fatalf("gate flagged ignored output: %v", err) + } + }) +} + +// The delivery gates and the tagged producer command the workflows must wire. +const ( + protocolGateRun = "make check-protocol" + bindingsGateRun = "make check-wails-bindings" + wailsBuildRun = "wails build -tags webkit2_41" +) + +// workflowStep is the parsed subset of one GitHub Actions step the delivery +// gate assertions act on. +type workflowStep struct { + Name string `yaml:"name"` + Run string `yaml:"run"` + If string `yaml:"if"` + ContinueOnError *bool `yaml:"continue-on-error"` +} + +// workflowJob and workflowFile are the parsed subset of the workflow files: +// only the steps of each job matter here. +type workflowJob struct { + Steps []workflowStep `yaml:"steps"` +} + +type workflowFile struct { + Jobs map[string]*workflowJob `yaml:"jobs"` +} + +// parseWorkflow reads and decodes one workflow file. +func parseWorkflow(t *testing.T, path string) workflowFile { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading workflow %s: %v", path, err) + } + var workflow workflowFile + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatalf("decoding workflow %s: %v", path, err) + } + return workflow +} + +// parsedSteps returns one workflow job's parsed steps, failing the test when +// the job is missing. +func parsedSteps(t *testing.T, path, job string) []workflowStep { + t.Helper() + workflow := parseWorkflow(t, path) + parsed, ok := workflow.Jobs[job] + if !ok { + t.Fatalf("workflow %s has no %s job", path, job) + } + return parsed.Steps +} + +// findRunStep returns the index of the first step whose trimmed run command is +// exactly want, or -1. +func findRunStep(steps []workflowStep, want string) int { + for i, step := range steps { + if strings.TrimSpace(step.Run) == want { + return i + } + } + return -1 +} + +// findContainingRunStep returns the index of the first step whose run command +// contains want, or -1. +func findContainingRunStep(steps []workflowStep, want string) int { + for i, step := range steps { + if strings.Contains(step.Run, want) { + return i + } + } + return -1 +} + +// findNamedStep returns the index of the first step named want, or -1. +func findNamedStep(steps []workflowStep, want string) int { + for i, step := range steps { + if step.Name == want { + return i + } + } + return -1 +} + +// stepBypass names the bypass on one step, or "" when it is unconditional. +func stepBypass(step workflowStep) string { + if step.If != "" { + return "conditionally skipped" + } + if step.ContinueOnError != nil { + return "allowed to fail" + } + return "" +} + +// checkDeliveryGates checks the fixed delivery-gate wiring in one job's steps +// and returns the first violation: make check-protocol and +// make check-wails-bindings must be exact, unconditional run steps, the tagged +// Wails build must be an unconditional step before the binding check, and both +// gates must precede every Package release assets or Upload release assets step +// the job declares. The protocol check has no ordering constraint against the +// Wails build, and a job without publication steps (Test) skips that last rule. +func checkDeliveryGates(steps []workflowStep) error { + wails := findContainingRunStep(steps, wailsBuildRun) + if wails < 0 { + return fmt.Errorf("missing the tagged Wails build command") + } + if bypass := stepBypass(steps[wails]); bypass != "" { + return fmt.Errorf("Wails build is %s", bypass) + } + bindings := findRunStep(steps, bindingsGateRun) + if bindings < 0 { + return fmt.Errorf("missing the exact run step %s", bindingsGateRun) + } + if bypass := stepBypass(steps[bindings]); bypass != "" { + return fmt.Errorf("%s is %s", bindingsGateRun, bypass) + } + if bindings < wails { + return fmt.Errorf("%s runs before the Wails build", bindingsGateRun) + } + protocol := findRunStep(steps, protocolGateRun) + if protocol < 0 { + return fmt.Errorf("missing the exact run step %s", protocolGateRun) + } + if bypass := stepBypass(steps[protocol]); bypass != "" { + return fmt.Errorf("%s is %s", protocolGateRun, bypass) + } + for _, publication := range []string{"Package release assets", "Upload release assets"} { + index := findNamedStep(steps, publication) + if index < 0 { + continue + } + if protocol > index { + return fmt.Errorf("%s does not precede %s", protocolGateRun, publication) + } + if bindings > index { + return fmt.Errorf("%s does not precede %s", bindingsGateRun, publication) + } + } + return nil +} + +// TestDeliveryGateWorkflowWiring parses the repository's Test and Release +// workflows and requires the delivery gates to stay wired to their real +// producers: make check-protocol and make check-wails-bindings are exact, +// unconditional run steps, the binding check follows the tagged Wails build, +// and the Release checks both precede packaging and upload. Neither workflow +// constrains the protocol check against the Wails build, and the Release +// workflow must still declare its two publication steps. Mutation rows prove +// removed, conditionally skipped, allowed-to-fail, suppressed and reordered +// gates are rejected, and that a protocol check before the build is accepted +// in both workflows. This verifies repository wiring, not GitHub branch +// protection or hosted execution. +func TestDeliveryGateWorkflowWiring(t *testing.T) { + testPath := filepath.Join("..", ".github", "workflows", "test.yml") + releasePath := filepath.Join("..", ".github", "workflows", "release.yml") + + testSteps := parsedSteps(t, testPath, "test") + if err := checkDeliveryGates(testSteps); err != nil { + t.Errorf("test workflow: %v", err) + } + releaseSteps := parsedSteps(t, releasePath, "build") + if err := checkDeliveryGates(releaseSteps); err != nil { + t.Errorf("release workflow: %v", err) + } + for _, publication := range []string{"Package release assets", "Upload release assets"} { + if findNamedStep(releaseSteps, publication) < 0 { + t.Errorf("release workflow is missing the %s step", publication) + } + } + + // protocolBeforeWails moves the exact protocol gate step to just before the + // tagged Wails build: the order both workflows must accept. + protocolBeforeWails := func(steps []workflowStep) []workflowStep { + from := findRunStep(steps, protocolGateRun) + to := findContainingRunStep(steps, wailsBuildRun) + step := steps[from] + steps = slices.Delete(steps, from, from+1) + if from < to { + to-- + } + return slices.Insert(steps, to, step) + } + allowedToFail := true + + for _, mutation := range []struct { + name string + path string + job string + accept bool + mutate func([]workflowStep) []workflowStep + }{ + {"protocol gate before the Wails build in Test is accepted", testPath, "test", true, protocolBeforeWails}, + {"protocol gate before the Wails build in Release is accepted", releasePath, "build", true, protocolBeforeWails}, + {"removed binding gate", testPath, "test", false, func(steps []workflowStep) []workflowStep { + index := findRunStep(steps, bindingsGateRun) + return slices.Delete(steps, index, index+1) + }}, + {"conditional binding gate", testPath, "test", false, func(steps []workflowStep) []workflowStep { + steps[findRunStep(steps, bindingsGateRun)].If = "${{ false }}" + return steps + }}, + {"continue-on-error binding gate", testPath, "test", false, func(steps []workflowStep) []workflowStep { + steps[findRunStep(steps, bindingsGateRun)].ContinueOnError = &allowedToFail + return steps + }}, + {"suppressed binding gate", testPath, "test", false, func(steps []workflowStep) []workflowStep { + steps[findRunStep(steps, bindingsGateRun)].Run += " || true" + return steps + }}, + {"removed protocol gate", testPath, "test", false, func(steps []workflowStep) []workflowStep { + index := findRunStep(steps, protocolGateRun) + return slices.Delete(steps, index, index+1) + }}, + {"binding gate before the Wails build", testPath, "test", false, func(steps []workflowStep) []workflowStep { + bindings, wails := findRunStep(steps, bindingsGateRun), findContainingRunStep(steps, wailsBuildRun) + steps[bindings], steps[wails] = steps[wails], steps[bindings] + return steps + }}, + {"removed release binding gate", releasePath, "build", false, func(steps []workflowStep) []workflowStep { + index := findRunStep(steps, bindingsGateRun) + return slices.Delete(steps, index, index+1) + }}, + {"continue-on-error release protocol gate", releasePath, "build", false, func(steps []workflowStep) []workflowStep { + steps[findRunStep(steps, protocolGateRun)].ContinueOnError = &allowedToFail + return steps + }}, + {"suppressed release protocol gate", releasePath, "build", false, func(steps []workflowStep) []workflowStep { + steps[findRunStep(steps, protocolGateRun)].Run += " || true" + return steps + }}, + {"release binding gate after packaging", releasePath, "build", false, func(steps []workflowStep) []workflowStep { + index := findRunStep(steps, bindingsGateRun) + step := steps[index] + return append(slices.Delete(steps, index, index+1), step) + }}, + {"release binding gate before the Wails build", releasePath, "build", false, func(steps []workflowStep) []workflowStep { + bindings, wails := findRunStep(steps, bindingsGateRun), findContainingRunStep(steps, wailsBuildRun) + steps[bindings], steps[wails] = steps[wails], steps[bindings] + return steps + }}, + {"release Wails build allowed to fail", releasePath, "build", false, func(steps []workflowStep) []workflowStep { + steps[findContainingRunStep(steps, wailsBuildRun)].ContinueOnError = &allowedToFail + return steps + }}, + } { + t.Run(mutation.name, func(t *testing.T) { + steps := mutation.mutate(parsedSteps(t, mutation.path, mutation.job)) + err := checkDeliveryGates(steps) + if mutation.accept { + if err != nil { + t.Fatalf("accepted workflow mutation reported %v", err) + } + return + } + if err == nil { + t.Fatal("mutated workflow was accepted") + } + }) + } } diff --git a/protocol/openapi.yaml b/protocol/openapi.yaml index 919d7889..fca85473 100644 --- a/protocol/openapi.yaml +++ b/protocol/openapi.yaml @@ -1,13 +1,13 @@ # Lightcode Runtime protocol — the single source of truth for HTTP paths, # request/response DTOs, closed enums, error payloads, and SSE event frames. -# Wire version "1" is the protocol identity, independent of the binary +# Wire version "2" is the protocol identity, independent of the binary # release version. This file is hand-maintained; the Go and TypeScript # artifacts under protocol/ and frontend/src/generated/protocol/ are # generated from it and are never hand-edited. openapi: "3.0.3" info: title: lightcode-protocol - version: "1" + version: "2" servers: - url: "http://127.0.0.1" # Every path requires the bearer credential; it is declared once here and @@ -878,7 +878,7 @@ components: protocol_version: type: string enum: - - "1" + - "2" Scope: description: > One SSE event's applicable scope, discriminated by kind. Every @@ -2001,10 +2001,8 @@ components: - BaseUrl - EnvironmentVariable - Headers - - Options - SystemRole - UsageInStream - - MaxTokensField - ExtraBody - Discovery - ProtocolMetadata @@ -2014,10 +2012,8 @@ components: - base_url - api_key_env - headers - - options - system_role - usage_in_stream - - max_tokens_field - extra_body - discovery - protocol_metadata @@ -2055,14 +2051,10 @@ components: type: object additionalProperties: type: string - options: - type: object system_role: $ref: "#/components/schemas/SystemRole" usage_in_stream: type: boolean - max_tokens_field: - type: string extra_body: type: object discovery: @@ -2161,7 +2153,6 @@ components: - user_headers - system_role - usage_in_stream - - max_tokens_field - discovery - hidden - models @@ -2193,14 +2184,10 @@ components: type: object additionalProperties: type: string - options: - type: object system_role: $ref: "#/components/schemas/SystemRole" usage_in_stream: type: boolean - max_tokens_field: - type: string extra_body: type: object discovery: diff --git a/protocol/protocol.gen.go b/protocol/protocol.gen.go index 795a440b..6cde9e6b 100644 --- a/protocol/protocol.gen.go +++ b/protocol/protocol.gen.go @@ -160,13 +160,13 @@ func (e ErrorCode) Valid() bool { // Defines values for HealthProtocolVersion. const ( - N1 HealthProtocolVersion = "1" + N2 HealthProtocolVersion = "2" ) // Valid indicates whether the value is a known member of the HealthProtocolVersion enum. func (e HealthProtocolVersion) Valid() bool { switch e { - case N1: + case N2: return true default: return false @@ -466,9 +466,7 @@ const ( ProviderFieldExtraBody ProviderField = "extra_body" ProviderFieldHeaders ProviderField = "headers" ProviderFieldHidden ProviderField = "hidden" - ProviderFieldMaxTokensField ProviderField = "max_tokens_field" ProviderFieldName ProviderField = "name" - ProviderFieldOptions ProviderField = "options" ProviderFieldProtocolMetadata ProviderField = "protocol_metadata" ProviderFieldSystemRole ProviderField = "system_role" ProviderFieldUsageInStream ProviderField = "usage_in_stream" @@ -489,12 +487,8 @@ func (e ProviderField) Valid() bool { return true case ProviderFieldHidden: return true - case ProviderFieldMaxTokensField: - return true case ProviderFieldName: return true - case ProviderFieldOptions: - return true case ProviderFieldProtocolMetadata: return true case ProviderFieldSystemRole: @@ -1290,10 +1284,8 @@ type Provider struct { Hidden bool `json:"hidden"` Id string `json:"id"` KeySource ProviderKeySource `json:"key_source"` - MaxTokensField string `json:"max_tokens_field"` Models []ModelView `json:"models"` Name string `json:"name"` - Options *map[string]interface{} `json:"options,omitempty"` ProtocolMetadata *ProtocolMetadata `json:"protocol_metadata,omitempty"` SystemRole SystemRole `json:"system_role"` UsageInStream bool `json:"usage_in_stream"` @@ -1317,9 +1309,7 @@ type ProviderEdit struct { ExtraBody *map[string]interface{} `json:"extra_body,omitempty"` Headers *map[string]string `json:"headers,omitempty"` Hidden *bool `json:"hidden,omitempty"` - MaxTokensField *string `json:"max_tokens_field,omitempty"` Name *string `json:"name,omitempty"` - Options *map[string]interface{} `json:"options,omitempty"` ProtocolMetadata *ProtocolMetadata `json:"protocol_metadata,omitempty"` SystemRole *SystemRole `json:"system_role,omitempty"` UsageInStream *bool `json:"usage_in_stream,omitempty"` diff --git a/protocol/testdata/protocol_contract_fixtures.json b/protocol/testdata/protocol_contract_fixtures.json index 44e0a2f5..f9173c8b 100644 --- a/protocol/testdata/protocol_contract_fixtures.json +++ b/protocol/testdata/protocol_contract_fixtures.json @@ -514,26 +514,26 @@ "accept": [ { "name": "minimal", - "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"1\"}" + "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"2\"}" } ], "reject": [ { "name": "foreign-version", - "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"2\"}" + "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"1\"}" }, { "name": "bad-instance-pattern", - "json": "{\"instance_id\":\"XYZ\",\"protocol_version\":\"1\"}", + "json": "{\"instance_id\":\"XYZ\",\"protocol_version\":\"2\"}", "typescript": false }, { "name": "missing-instance", - "json": "{\"protocol_version\":\"1\"}" + "json": "{\"protocol_version\":\"2\"}" }, { "name": "unknown-member", - "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"1\",\"extra\":true}" + "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"2\",\"extra\":true}" } ] }, @@ -956,21 +956,30 @@ "accept": [ { "name": "minimal", - "json": "{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"max_tokens_field\":\"fixture\",\"discovery\":false,\"hidden\":false,\"models\":[]}" + "json": "{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"discovery\":false,\"hidden\":false,\"models\":[]}" }, { "name": "with-extras", - "json": "{\"id\":\"prov\",\"name\":\"Provider\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"https://provider.test/v1\",\"api_key_env\":\"\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"max_tokens_field\":\"max_tokens\",\"discovery\":false,\"hidden\":false,\"models\":[],\"options\":{\"a\":1},\"extra_body\":{\"temperature\":0.1},\"protocol_metadata\":{\"family\":\"openai\"}}" + "json": "{\"id\":\"prov\",\"name\":\"Provider\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"https://provider.test/v1\",\"api_key_env\":\"\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"discovery\":false,\"hidden\":false,\"models\":[],\"extra_body\":{\"temperature\":0.1},\"protocol_metadata\":{\"family\":\"openai\"}}" } ], - "reject": [] + "reject": [ + { + "name": "retired-max-tokens-field", + "json": "{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"discovery\":false,\"hidden\":false,\"models\":[],\"max_tokens_field\":\"max_tokens\"}" + }, + { + "name": "retired-options", + "json": "{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"discovery\":false,\"hidden\":false,\"models\":[],\"options\":{\"retries\":3}}" + } + ] }, { "schema": "ProviderDetail", "accept": [ { "name": "minimal", - "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"provider\":{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"max_tokens_field\":\"fixture\",\"discovery\":false,\"hidden\":false,\"models\":[]}}" + "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"provider\":{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"discovery\":false,\"hidden\":false,\"models\":[]}}" } ], "reject": [] @@ -999,6 +1008,14 @@ { "name": "non-string", "json": "7" + }, + { + "name": "retired-options", + "json": "\"options\"" + }, + { + "name": "retired-max-tokens-field", + "json": "\"max_tokens_field\"" } ] }, @@ -1038,13 +1055,21 @@ }, { "name": "full-patch", - "json": "{\"name\":\"Renamed\",\"base_url\":\"https://edited.test/v1\",\"api_key_env\":\"EDITED_KEY\",\"headers\":{\"X-Edit\":\"1\"},\"options\":{\"a\":1},\"system_role\":\"user\",\"usage_in_stream\":true,\"max_tokens_field\":\"max_completion_tokens\",\"extra_body\":{\"temperature\":0.1},\"discovery\":true,\"protocol_metadata\":{\"family\":\"openai\"},\"hidden\":false}" + "json": "{\"name\":\"Renamed\",\"base_url\":\"https://edited.test/v1\",\"api_key_env\":\"EDITED_KEY\",\"headers\":{\"X-Edit\":\"1\"},\"system_role\":\"user\",\"usage_in_stream\":true,\"extra_body\":{\"temperature\":0.1},\"discovery\":true,\"protocol_metadata\":{\"family\":\"openai\"},\"hidden\":false}" } ], "reject": [ { "name": "unknown-field", "json": "{\"nope\":true}" + }, + { + "name": "retired-options", + "json": "{\"options\":{\"retries\":3}}" + }, + { + "name": "retired-max-tokens-field", + "json": "{\"max_tokens_field\":\"max_tokens\"}" } ] }, @@ -1073,7 +1098,7 @@ "accept": [ { "name": "minimal", - "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"result\":{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"max_tokens_field\":\"fixture\",\"discovery\":false,\"hidden\":false,\"models\":[]}}" + "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"result\":{\"id\":\"fixture\",\"name\":\"fixture\",\"builtin\":false,\"connected\":false,\"key_source\":\"none\",\"base_url\":\"fixture\",\"api_key_env\":\"fixture\",\"headers\":{},\"user_headers\":{},\"system_role\":\"system\",\"usage_in_stream\":false,\"discovery\":false,\"hidden\":false,\"models\":[]}}" }, { "name": "null-result", diff --git a/runtime/configuration_metadata.go b/runtime/configuration_metadata.go index 81b5af69..20d1fa1f 100644 --- a/runtime/configuration_metadata.go +++ b/runtime/configuration_metadata.go @@ -187,7 +187,7 @@ func modelIdentityPresent(eff *catalog.Provider, rawModels map[string]any, model return present } -// writeProviderEditMembers writes the eight non-transport ProviderEdit +// writeProviderEditMembers writes the seven non-transport ProviderEdit // members onto a raw provider map: every provided member replaces its whole // value exactly as supplied, zeros, falses, and whitespace included — the // pointer presence is the patch, and the catalog validators decide @@ -203,9 +203,6 @@ func writeProviderEditMembers(pm map[string]any, patch protocol.ProviderEdit) { if patch.UsageInStream != nil { pm["usage_in_stream"] = *patch.UsageInStream } - if patch.MaxTokensField != nil { - pm["max_tokens_field"] = *patch.MaxTokensField - } if patch.ExtraBody != nil { pm["extra_body"] = *patch.ExtraBody } @@ -407,9 +404,6 @@ func providerTransportRaw(patch protocol.ProviderEdit, baseURL string, headers m if patch.Headers != nil { writeTransportHeaders(transport, headers) } - if patch.Options != nil { - transport["options"] = *patch.Options - } return transport } @@ -460,9 +454,6 @@ func (s *configurationService) editProviderUpdate(providerID string, patch proto if patch.ApiKeyEnv != nil { transport["api_key_env"] = env } - if patch.Options != nil { - transport["options"] = *patch.Options - } if patch.Headers != nil { writeTransportHeaders(transport, headers) } @@ -509,7 +500,6 @@ var providerResetTransport = map[protocol.ProviderField]bool{ protocol.ProviderFieldBaseUrl: true, protocol.ProviderFieldEnvironmentVariable: true, protocol.ProviderFieldHeaders: true, - protocol.ProviderFieldOptions: true, } // editProviderFieldReset builds the provider-field reset edit: the shared diff --git a/runtime/configuration_metadata_test.go b/runtime/configuration_metadata_test.go index 5a1a00bf..efe1fca1 100644 --- a/runtime/configuration_metadata_test.go +++ b/runtime/configuration_metadata_test.go @@ -74,7 +74,7 @@ func metadataBundledFS() fstest.MapFS { const metadataConfigDocument = `{ "providers": { "prov": { - "transport": {"base_url": "https://prov.test/v1", "api_key_env": "META_TEST_KEY", "headers": {"X-Trace": "t1"}, "options": {"retries": 3}}, + "transport": {"base_url": "https://prov.test/v1", "api_key_env": "META_TEST_KEY", "headers": {"X-Trace": "t1"}}, "discovery": false, "extra_body": {"side": 1, "big": 9007199254740993}, "models": { @@ -226,10 +226,8 @@ func TestMetadataEditCreateCustomProvider(t *testing.T) { BaseUrl: &[]string{"https://new.test/v1"}[0], ApiKeyEnv: &[]string{"NEW_KEY"}[0], Headers: &map[string]string{"X-Custom": "c"}, - Options: &map[string]any{"retries": 2}, SystemRole: &[]protocol.SystemRole{"user"}[0], UsageInStream: &[]bool{false}[0], - MaxTokensField: &[]string{"max_completion_tokens"}[0], ExtraBody: &map[string]any{"side": json.Number("9007199254740993")}, Discovery: &[]bool{false}[0], ProtocolMetadata: &meta, @@ -247,7 +245,7 @@ func TestMetadataEditCreateCustomProvider(t *testing.T) { } if prov.Transport.BaseURL != "https://new.test/v1" || prov.Transport.APIKeyEnv != "NEW_KEY" || prov.Name != "Created" || prov.SystemRole != catalog.SystemRole("user") || - prov.UsageInStream || prov.MaxTokensField != "max_completion_tokens" || prov.Discovery { + prov.UsageInStream || prov.Discovery { t.Fatalf("created provider = %+v, want the provided members", prov) } if usableModelCount(prov) != 2 { @@ -502,10 +500,8 @@ func TestMetadataEditUpdateCustomProviderPatchSemantics(t *testing.T) { var prov struct { Transport struct { Headers map[string]string `json:"headers"` - Options map[string]any `json:"options"` } `json:"transport"` - ExtraBody *map[string]any `json:"extra_body"` - MaxTokensField *string `json:"max_tokens_field"` + ExtraBody *map[string]any `json:"extra_body"` } var providersRoot map[string]json.RawMessage if err := json.Unmarshal(fileRoot(t, h.configPath)["providers"], &providersRoot); err != nil { @@ -521,14 +517,6 @@ func TestMetadataEditUpdateCustomProviderPatchSemantics(t *testing.T) { t.Fatalf("headers = %v, want the absent member untouched", prov.Transport.Headers) } - // The provided empty max_tokens_field is refused by the raw validator — - // the wholesale value replaces the member and the catalog validation - // decides. - emptyString := "" - if _, err = svc.mutate(context.Background(), svc.editProviderUpdate("prov", protocol.ProviderEdit{MaxTokensField: &emptyString})); err == nil || !errors.Is(err, ErrConfiguration) { - t.Fatalf("empty max_tokens_field = %v, want a rejected candidate", err) - } - // The headers exception: an empty provided object deletes the raw user // headers member — the file, not merely the filtered GET. emptyHeaders := map[string]string{} @@ -697,21 +685,11 @@ func TestMetadataEditUpdateBuiltinProvider(t *testing.T) { t.Fatal("builtin usage_in_stream = true, want the override") } }}, - {"max_tokens_field", protocol.ProviderEdit{MaxTokensField: &[]string{"max_completion_tokens"}[0]}, func(p *catalog.Provider) { - if p.MaxTokensField != "max_completion_tokens" { - t.Fatalf("builtin max_tokens_field = %q, want the override", p.MaxTokensField) - } - }}, {"protocol_metadata", protocol.ProviderEdit{ProtocolMetadata: &protocol.ProtocolMetadata{Family: &[]string{"fam"}[0]}}, func(p *catalog.Provider) { if p.ProtocolMetadata == nil || p.ProtocolMetadata.Family != "fam" { t.Fatalf("builtin protocol_metadata = %+v, want the override", p.ProtocolMetadata) } }}, - {"options", protocol.ProviderEdit{Options: &map[string]any{"retries": 1}}, func(p *catalog.Provider) { - if p.Transport.Options == nil || p.Transport.Options["retries"] != json.Number("1") { - t.Fatalf("builtin options = %v, want the override", p.Transport.Options) - } - }}, } { t.Run(row.name, func(t *testing.T) { candidate, err := svc.mutate(context.Background(), svc.editProviderUpdate("bstatic", row.patch)) @@ -911,11 +889,10 @@ func TestMetadataEditResetProviderFields(t *testing.T) { role := protocol.SystemRole("developer") meta := protocol.ProtocolMetadata{Family: &[]string{"fam"}[0]} discovery := false - mtf := "max_completion_tokens" hidden := true if _, err := svc.mutate(context.Background(), svc.editProviderUpdate("prov", protocol.ProviderEdit{ Name: &name, ExtraBody: &extra, SystemRole: &role, UsageInStream: &stream, - ProtocolMetadata: &meta, Discovery: &discovery, MaxTokensField: &mtf, Hidden: &hidden, + ProtocolMetadata: &meta, Discovery: &discovery, Hidden: &hidden, })); err != nil { t.Fatalf("override seed: %v", err) } @@ -930,7 +907,6 @@ func TestMetadataEditResetProviderFields(t *testing.T) { protocol.ProviderFieldUsageInStream, protocol.ProviderFieldProtocolMetadata, protocol.ProviderFieldDiscovery, - protocol.ProviderFieldMaxTokensField, protocol.ProviderFieldHidden, } { candidate, err := svc.mutate(context.Background(), svc.editProviderFieldReset("prov", field)) @@ -956,7 +932,6 @@ func TestMetadataEditResetProviderFields(t *testing.T) { // Transport resets remove exactly their own transport member. for _, field := range []protocol.ProviderField{ protocol.ProviderFieldHeaders, - protocol.ProviderFieldOptions, } { if _, err := svc.mutate(context.Background(), svc.editProviderFieldReset("prov", field)); err != nil { t.Fatalf("reset %q: %v", field, err) @@ -1072,11 +1047,6 @@ func TestMetadataEditResetProviderFields(t *testing.T) { } generation++ drainMutationEvent(t, sub, strconv.Itoa(generation)) - if _, err := svc.mutate(context.Background(), svc.editProviderFieldReset("bstatic", protocol.ProviderFieldOptions)); err != nil { - t.Fatalf("builtin absent-override reset: %v", err) - } - generation++ - drainMutationEvent(t, sub, strconv.Itoa(generation)) } // --- model upsert --- @@ -1292,7 +1262,7 @@ func TestMetadataEditModelDelete(t *testing.T) { // editor (without a reload) still publishes: the identity is effective, // no user node remains, and the post-state carries no subject. The // external write removes only "m" from the raw layer. - external := `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":"META_TEST_KEY","headers":{"X-Trace":"t1"},"options":{"retries":3}},"discovery":false,"extra_body":{"side":1,"big":9007199254740993},"models":{"wide":{"context_window":8192}}},"other":{"transport":{"base_url":"https://other.test/v1","api_key_env":"OTHER_TEST_KEY"},"discovery":false,"models":{"o":{"context_window":9007199254740993}}}},"custom_flag":true}` + external := `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":"META_TEST_KEY","headers":{"X-Trace":"t1"}},"discovery":false,"extra_body":{"side":1,"big":9007199254740993},"models":{"wide":{"context_window":8192}}},"other":{"transport":{"base_url":"https://other.test/v1","api_key_env":"OTHER_TEST_KEY"},"discovery":false,"models":{"o":{"context_window":9007199254740993}}}},"custom_flag":true}` writeServiceFile(t, h.configPath, external) candidate, err = svc.mutate(context.Background(), svc.editModelDelete("prov", "m")) if err != nil || candidate.snapshot.generation != 5 { @@ -2504,11 +2474,11 @@ func TestMetadataEditFieldValuesExactAndFileAPIEquivalent(t *testing.T) { // TestMetadataEditPaddedInvalidValuesNeverBecomeValid pins the uniform // candidate governance for invalid padded values: the selected validators, -// not the writers, decide them — an api_key_env, max_tokens_field, or -// system_role carrying padding refuses the edit with the owning file -// untouched, and the identical file-written member makes the provider -// invalid in the published catalog (dropped with a warning) so the padded -// value never becomes effective through either path. +// not the writers, decide them — an api_key_env or system_role carrying +// padding refuses the edit with the owning file untouched, and the identical +// file-written member makes the provider invalid in the published catalog +// (dropped with a warning) so the padded value never becomes effective +// through either path. func TestMetadataEditPaddedInvalidValuesNeverBecomeValid(t *testing.T) { refusals := []struct { name string @@ -2520,10 +2490,6 @@ func TestMetadataEditPaddedInvalidValuesNeverBecomeValid(t *testing.T) { func(doc string) string { return strings.Replace(doc, `"api_key_env": "META_TEST_KEY"`, `"api_key_env": " NEW_TEST_KEY "`, 1) }, `"api_key_env": " NEW_TEST_KEY "`}, - {"padded max_tokens_field", protocol.ProviderEdit{MaxTokensField: &[]string{" max_completion_tokens "}[0]}, - func(doc string) string { - return strings.Replace(doc, `"prov": {`, `"prov": {"max_tokens_field": " max_completion_tokens ",`, 1) - }, `"max_tokens_field": " max_completion_tokens "`}, {"padded system_role", protocol.ProviderEdit{SystemRole: &[]protocol.SystemRole{" developer "}[0]}, func(doc string) string { return strings.Replace(doc, `"prov": {`, `"prov": {"system_role": " developer ",`, 1) @@ -2562,6 +2528,45 @@ func TestMetadataEditPaddedInvalidValuesNeverBecomeValid(t *testing.T) { } } +// TestMetadataEditRetiredProviderControlsFollowUnknownFieldPolicy pins the +// retired controls' shared unknown-field policy through the file path: a +// user definition carrying max_tokens_field or transport.options is an +// ordinary invalid provider, so the publication drops it under the shared +// user_config_skip warning while the raw member stays in the file untouched. +func TestMetadataEditRetiredProviderControlsFollowUnknownFieldPolicy(t *testing.T) { + for _, row := range []struct { + name string + fileDoc func(doc string) string + rawProbe string + }{ + {"retired max_tokens_field", func(doc string) string { + return strings.Replace(doc, `"prov": {`, `"prov": {"max_tokens_field": "max_completion_tokens",`, 1) + }, `"max_tokens_field": "max_completion_tokens"`}, + {"retired transport options", func(doc string) string { + return strings.Replace(doc, `"headers": {"X-Trace": "t1"}`, `"headers": {"X-Trace": "t1"}, "options": {"retries": 3}`, 1) + }, `"options": {"retries": 3}`}, + } { + t.Run(row.name, func(t *testing.T) { + h, svc, _ := newOverlayFixture(t) + writeServiceFile(t, h.configPath, row.fileDoc(metadataConfigDocument)) + viaFile, err := svc.publish(context.Background()) + if err != nil { + t.Fatalf("file-side publish of the retired member: %v", err) + } + if viaFile.snapshot.catalog.Providers["prov"] != nil { + t.Fatalf("the %s became effective through the file path", row.name) + } + warnings := viaFile.snapshot.catalogWarnings + if len(warnings) == 0 || warnings[0].Kind != "user_config_skip" || warnings[0].Provider != "prov" { + t.Fatalf("catalog warnings = %#v, want the shared user_config_skip drop", warnings) + } + if data, rerr := os.ReadFile(h.configPath); rerr != nil || !strings.Contains(string(data), row.rawProbe) { + t.Fatalf("the raw file = (%q, %v), want the retired member retained", data, rerr) + } + }) + } +} + // TestMetadataEditExternalRemovalRefusesWithoutReconstruction pins the // externally-removed custom provider: while the captured catalog still // knows the identity, the latest raw layer no longer owns a definition — an @@ -2713,16 +2718,19 @@ func TestMetadataEditProviderTargetUsesSharedRawObjectAccess(t *testing.T) { // TestMetadataEditInvalidResetFields pins the closed reset enums: a field // outside the shared vocabulary refuses invalid before any write, for the -// provider and the model reset paths alike. +// provider and the model reset paths alike — the retired provider field +// literals included. func TestMetadataEditInvalidResetFields(t *testing.T) { h := newMetadataHarness(t) writeServiceFile(t, h.configPath, metadataConfigDocument) svc, sub := metadataService(t, h) before, first, warnRev := metadataBaseline(t, svc, h.configPath) - _, err := svc.mutate(context.Background(), svc.editProviderFieldReset("prov", protocol.ProviderField("bogus"))) - assertMetadataRefused(t, svc, sub, h.configPath, before, first, warnRev, err, harness.ErrInvalid) - _, err = svc.mutate(context.Background(), svc.editModelFieldReset("prov", "m", protocol.ModelField("bogus"))) + for _, field := range []protocol.ProviderField{"bogus", "options", "max_tokens_field"} { + _, err := svc.mutate(context.Background(), svc.editProviderFieldReset("prov", field)) + assertMetadataRefused(t, svc, sub, h.configPath, before, first, warnRev, err, harness.ErrInvalid) + } + _, err := svc.mutate(context.Background(), svc.editModelFieldReset("prov", "m", protocol.ModelField("bogus"))) assertMetadataRefused(t, svc, sub, h.configPath, before, first, warnRev, err, harness.ErrInvalid) } diff --git a/runtime/preparation.go b/runtime/preparation.go index 29564f08..92fba06e 100644 --- a/runtime/preparation.go +++ b/runtime/preparation.go @@ -577,9 +577,8 @@ func (p *preparation) concreteTransport(provider *catalog.Provider, entry *catal // streamed-usage flag as the catalog build already resolved them; sidecar // extra-body layers re-encoded with raw numbers preserved; the same-provider // source-family map from the provider's models; and the retained opt-in -// wire-debug directory. Transport.Options stays catalog/config data feeding -// the discovery fingerprint and is never copied here; no timeout input or -// HTTP option exists. +// wire-debug directory. No transport option feeds this conversion; no timeout +// input or HTTP option exists. func (p *preparation) resolveTransport(provider *catalog.Provider, entry *catalog.Model, ref model.ModelRef, apiKey string) (model.ResolvedTransport, error) { resolved := model.ResolvedTransport{ Model: ref, diff --git a/runtime/protocol_composed_test.go b/runtime/protocol_composed_test.go index 48b9076c..e40f63c2 100644 --- a/runtime/protocol_composed_test.go +++ b/runtime/protocol_composed_test.go @@ -536,10 +536,11 @@ func TestProtocolComposedFlow(t *testing.T) { defer func() { _ = r.Close(ctx) }() // The unauthorized-transport sibling: a strict record carrying a - // wrong credential for this live endpoint is no usable owner. + // compatible wire version and a wrong credential for this live + // endpoint is no usable owner. foreign := filepath.Join(t.TempDir(), "foreign.json") wrongCredential := strings.Repeat("0", 64) - composedWriteFile(t, foreign, fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"1","credential":%q}`, ps.Endpoint(), c.InstanceID(), wrongCredential)) + composedWriteFile(t, foreign, fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":%q,"credential":%q}`, ps.Endpoint(), c.InstanceID(), protocol.N2, wrongCredential)) if _, err := client.Connect(ctx, foreign); !errors.Is(err, client.ErrNoRuntime) { t.Fatalf("wrong-credential connect = %v, want ErrNoRuntime", err) } diff --git a/runtime/protocol_configuration.go b/runtime/protocol_configuration.go index 8a3c9911..a6e7da3e 100644 --- a/runtime/protocol_configuration.go +++ b/runtime/protocol_configuration.go @@ -439,10 +439,8 @@ func projectProvider(captured configurationCapture, prov *catalog.Provider) prot Hidden: prov.Hidden, Id: prov.ID, KeySource: protocol.ProviderKeySource(keySource), - MaxTokensField: prov.MaxTokensField, Models: projectModels(prov), Name: prov.Name, - Options: jsonMapPointer(prov.Transport.Options), SystemRole: protocol.SystemRole(prov.SystemRole), UsageInStream: prov.UsageInStream, UserHeaders: captured.snapshot.userHeaders(prov.ID), diff --git a/runtime/protocol_configuration_test.go b/runtime/protocol_configuration_test.go index 96e1ec21..dd6afaae 100644 --- a/runtime/protocol_configuration_test.go +++ b/runtime/protocol_configuration_test.go @@ -32,8 +32,7 @@ const configurationProvidersDocument = `{ "providers": { "prov": { "transport": {"base_url": "https://prov.test/v1", "api_key_env": "SHELL_TEST_KEY", - "headers": {"X-Trace": "t1", "Authorization": "Bearer forged", "Proxy-Authorization": "forged2", " authorization ": "forged3"}, - "options": {"retries": 3, "flag": true}}, + "headers": {"X-Trace": "t1", "Authorization": "Bearer forged", "Proxy-Authorization": "forged2", " authorization ": "forged3"}}, "discovery": false, "extra_body": {"side": "value", "big": 9007199254740993}, "protocol_metadata": {"family": "testfam", "must_preserve": ["x_keep"], "drop": ["x_drop"]}, @@ -1002,13 +1001,6 @@ func TestConfigurationProviderExtrasExactAndOwned(t *testing.T) { t.Fatalf("%s: prov extra_body side = %v, want the captured value", where, got) } exactJSONMember(where, "extra_body.big", (*view.ExtraBody)["big"], "9007199254740993") - if view.Options == nil { - t.Fatalf("%s: prov options missing, want the captured transport options", where) - } - exactJSONMember(where, "options.retries", (*view.Options)["retries"], "3") - if got := (*view.Options)["flag"]; got != true { - t.Fatalf("%s: prov options flag = %v, want the captured value", where, got) - } if view.ProtocolMetadata == nil || view.ProtocolMetadata.Family == nil || *view.ProtocolMetadata.Family != "testfam" { t.Fatalf("%s: prov protocol metadata family = %+v, want testfam", where, view.ProtocolMetadata) } @@ -1027,11 +1019,7 @@ func TestConfigurationProviderExtrasExactAndOwned(t *testing.T) { prov := findProvider(configView.Providers, "prov") checkExtras("getConfiguration", prov) // The remaining provider/model fields the sidecar fixture carries: - // the retained max-tokens field name, the effective system role, and - // the streamed-usage flag. - if prov.MaxTokensField != "max_tokens" { - t.Fatalf("prov max_tokens_field = %q, want the effective default", prov.MaxTokensField) - } + // the effective system role and the streamed-usage flag. if prov.SystemRole != protocol.SystemRole("system") || !prov.UsageInStream { t.Fatalf("prov system_role = %q usage_in_stream = %v, want the effective defaults", prov.SystemRole, prov.UsageInStream) } @@ -1065,7 +1053,6 @@ func TestConfigurationProviderExtrasExactAndOwned(t *testing.T) { } (*detail.Provider.ExtraBody)["side"] = "mutated" (*detail.Provider.ExtraBody)["big"] = nil - (*detail.Provider.Options)["retries"] = "mutated" *detail.Provider.ProtocolMetadata.Family = "mutated" (*detail.Provider.ProtocolMetadata.MustPreserve)[0] = "mutated" (*detail.Provider.ProtocolMetadata.Drop)[0] = "mutated" diff --git a/runtime/protocol_discovery_process_test.go b/runtime/protocol_discovery_process_test.go index eeef8788..7d1bef78 100644 --- a/runtime/protocol_discovery_process_test.go +++ b/runtime/protocol_discovery_process_test.go @@ -97,7 +97,7 @@ func TestRuntimeDiscoveryOwnershipAcrossProcesses(t *testing.T) { record := readDiscoveryRecord(t, recordPath) health := healthSnapshot(t, record) - if health.InstanceId != record.InstanceId || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != record.InstanceId || health.ProtocolVersion != protocol.N2 { t.Fatalf("authenticated health = %s/%s, want the record's %s/%s", health.InstanceId, health.ProtocolVersion, record.InstanceId, record.ProtocolVersion) } @@ -163,7 +163,7 @@ func TestRuntimeDiscoveryOwnershipAcrossProcesses(t *testing.T) { record := readDiscoveryRecord(t, recordPath) before := readDiscoveryBytes(t, recordPath) first := healthSnapshot(t, record) - if first.InstanceId != record.InstanceId || first.ProtocolVersion == "" || first.ProtocolVersion == protocol.N1 { + if first.InstanceId != record.InstanceId || first.ProtocolVersion == "" || first.ProtocolVersion == protocol.N2 { t.Fatalf("incompatible health = %s/%q, want the record's instance and a different nonempty version", first.InstanceId, first.ProtocolVersion) } @@ -295,14 +295,14 @@ func runDiscoveryChild(t *testing.T, dataDir string) { } fmt.Println("published") if mode == discoveryModeIncompatible { - ps.server.Handler = incompatibleHealthHandler(ps, ps.server.Handler, "2") + ps.server.Handler = incompatibleHealthHandler(ps, ps.server.Handler, "1") fmt.Println("incompatible") } case discoveryModeHold: // Ownership and the live listener only: the incumbent deliberately // publishes nothing, leaving any existing record untouched. case discoveryModeUnready: - ps.server.Handler = incompatibleHealthHandler(ps, ps.server.Handler, "2") + ps.server.Handler = incompatibleHealthHandler(ps, ps.server.Handler, "1") if err := ps.PublishDiscovery(recordPath); err == nil { t.Fatal("publication succeeded while authenticated health reported an incompatible version") } @@ -413,8 +413,8 @@ func readDiscoveryRecord(t *testing.T, path string) discoveryRecord { if err := decodeOneDocument(data, &record); err != nil { t.Fatalf("decode discovery record at %s: %v", path, err) } - if record.ProtocolVersion != string(protocol.N1) { - t.Fatalf("discovery record protocol version = %q, want %q", record.ProtocolVersion, protocol.N1) + if record.ProtocolVersion != string(protocol.N2) { + t.Fatalf("discovery record protocol version = %q, want %q", record.ProtocolVersion, protocol.N2) } if len(record.InstanceId) != 32 || strings.Trim(record.InstanceId, "0123456789abcdef") != "" { t.Fatalf("discovery instance %q is not 32 lowercase hex", record.InstanceId) diff --git a/runtime/protocol_handlers.go b/runtime/protocol_handlers.go index 3a412009..8bef740a 100644 --- a/runtime/protocol_handlers.go +++ b/runtime/protocol_handlers.go @@ -30,7 +30,7 @@ func (rt *protocolHandlers) GetHealth(w http.ResponseWriter, r *http.Request) { return } defer release() - health := protocol.Health{InstanceId: rt.protocolInstance(), ProtocolVersion: protocol.N1} + health := protocol.Health{InstanceId: rt.protocolInstance(), ProtocolVersion: protocol.N2} rt.writeQualifiedJSON(w, http.StatusOK, &health) } diff --git a/runtime/protocol_lifecycle_test.go b/runtime/protocol_lifecycle_test.go index 1477c20a..2fa80194 100644 --- a/runtime/protocol_lifecycle_test.go +++ b/runtime/protocol_lifecycle_test.go @@ -483,7 +483,7 @@ func TestProtocolServerDiscoveryPublication(t *testing.T) { t.Fatalf("decode published record %s: %v", string(data), err) } if published.Endpoint != ps.Endpoint() || published.InstanceId != ps.instance || - published.ProtocolVersion != string(protocol.N1) || published.Credential != ps.credential { + published.ProtocolVersion != string(protocol.N2) || published.Credential != ps.credential { t.Fatalf("published record = %+v, want the endpoint, identity, wire version, and credential", published) } diff --git a/runtime/protocol_server.go b/runtime/protocol_server.go index a3d35966..fd63eb17 100644 --- a/runtime/protocol_server.go +++ b/runtime/protocol_server.go @@ -235,7 +235,7 @@ func (ps *ProtocolServer) PublishDiscovery(path string) error { record, err := json.Marshal(discoveryRecord{ Endpoint: ps.endpoint, InstanceId: ps.instance, - ProtocolVersion: string(protocol.N1), + ProtocolVersion: string(protocol.N2), Credential: ps.credential, }) if err != nil { @@ -275,7 +275,7 @@ func (ps *ProtocolServer) verifyReady() error { if err := decodeOneDocument(body, &health); err != nil { return fmt.Errorf("decode health response: %w", err) } - if health.InstanceId != ps.instance || health.ProtocolVersion != protocol.N1 { + if health.InstanceId != ps.instance || health.ProtocolVersion != protocol.N2 { return fmt.Errorf("health response identity %q/%q does not match this server", health.InstanceId, health.ProtocolVersion) } return nil diff --git a/runtime/protocol_server_test.go b/runtime/protocol_server_test.go index e7d1d5eb..c5a3bb99 100644 --- a/runtime/protocol_server_test.go +++ b/runtime/protocol_server_test.go @@ -305,8 +305,8 @@ func TestProtocolServerSessionFamily(t *testing.T) { if health.JSON200 == nil || health.HTTPResponse.StatusCode != http.StatusOK { t.Fatalf("health = (%d, %v), want the 200 typed body", health.HTTPResponse.StatusCode, health.JSON200) } - if health.JSON200.InstanceId != ps.instance || health.JSON200.ProtocolVersion != protocol.N1 { - t.Fatalf("health = %+v, want instance %q and protocol version %q", health.JSON200, ps.instance, protocol.N1) + if health.JSON200.InstanceId != ps.instance || health.JSON200.ProtocolVersion != protocol.N2 { + t.Fatalf("health = %+v, want instance %q and protocol version %q", health.JSON200, ps.instance, protocol.N2) } workspace := filepath.Join(e.home, "mounted") diff --git a/runtime/protocol_transport_test.go b/runtime/protocol_transport_test.go index 53c1e52a..cfab3ba3 100644 --- a/runtime/protocol_transport_test.go +++ b/runtime/protocol_transport_test.go @@ -128,8 +128,8 @@ func TestProtocolServerAuthenticationOrder(t *testing.T) { t.Fatalf("decode versioned health: %v", err) } versionedResponse.Body.Close() - if health.ProtocolVersion != protocol.N1 { - t.Fatalf("versioned health protocol = %q, want %q", health.ProtocolVersion, protocol.N1) + if health.ProtocolVersion != protocol.N2 { + t.Fatalf("versioned health protocol = %q, want %q", health.ProtocolVersion, protocol.N2) } }) } @@ -302,6 +302,44 @@ func TestProtocolServerStrictBodies(t *testing.T) { t.Fatalf("rejected creation wrote a provider: %v", err) } + // The retired provider controls follow the shared strict boundary: + // the edit body's removed max_tokens_field and options members and + // the reset path's removed options enum are refused before any + // mutation, leaving the addressed provider and its revision + // untouched. + beforeRetired, err := r.getProvider(ctx, "validmods") + if err != nil { + t.Fatalf("read provider before the retired-control rows: %v", err) + } + retiredEdit := func(name, body string) { + t.Helper() + response := rawProtocol(t, http.MethodPut, protocolTarget(ps, "/v1/providers/detail?provider_id=validmods"), ps.credential, body) + defer response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("%s = %d, want the typed invalid rejection 400", name, response.StatusCode) + } + if typed := decodeProtocolError(t, response); typed.Code != protocol.Invalid { + t.Fatalf("%s error = %+v, want invalid", name, typed) + } + } + retiredEdit("retired edit max_tokens_field", `{"provider":{"max_tokens_field":"max_completion_tokens"}}`) + retiredEdit("retired edit options", `{"provider":{"options":{"retries":3}}}`) + retiredReset := rawProtocol(t, http.MethodDelete, protocolTarget(ps, "/v1/providers/fields/options?provider_id=validmods"), ps.credential, "") + defer retiredReset.Body.Close() + if retiredReset.StatusCode != http.StatusBadRequest { + t.Fatalf("retired reset options = %d, want the typed invalid rejection 400", retiredReset.StatusCode) + } + if typed := decodeProtocolError(t, retiredReset); typed.Code != protocol.Invalid { + t.Fatalf("retired reset options error = %+v, want invalid", typed) + } + afterRetired, err := r.getProvider(ctx, "validmods") + if err != nil { + t.Fatalf("read provider after the retired-control rows: %v", err) + } + if !reflect.DeepEqual(beforeRetired, afterRetired) { + t.Fatalf("a retired-control refusal changed the provider:\nbefore %+v\nafter %+v", beforeRetired, afterRetired) + } + // A null header value inside a declared typed map is not an opaque // map member: the discovery route rejects it before any fetch. nullHeader := rawProtocol(t, http.MethodPost, protocolTarget(ps, "/v1/providers/discover"), ps.credential, diff --git a/scripts/check-generated-files.sh b/scripts/check-generated-files.sh new file mode 100644 index 00000000..017b9256 --- /dev/null +++ b/scripts/check-generated-files.sh @@ -0,0 +1,20 @@ +# Freshness gate for generated artifacts, shared by `make check-protocol` and +# `make check-wails-bindings`: the regenerated worktree bytes must match the +# staged (index) state exactly, and no new untracked output may appear under +# the paths the caller names. The caller supplies those paths; this checker +# holds no built-in path list, staging mutation or build invocation. +set -eu + +if [ "$#" -eq 0 ]; then + echo "usage: check-generated-files.sh PATH..." >&2 + exit 1 +fi + +git diff --exit-code -- "$@" + +untracked=$(git ls-files --others --exclude-standard -- "$@") +if [ -n "$untracked" ]; then + echo "untracked generated artifacts:" >&2 + printf '%s\n' "$untracked" >&2 + exit 1 +fi diff --git a/scripts/check-protocol-generated.sh b/scripts/check-protocol-generated.sh deleted file mode 100644 index 96320824..00000000 --- a/scripts/check-protocol-generated.sh +++ /dev/null @@ -1,14 +0,0 @@ -# Freshness gate for the generated protocol artifacts, shared by -# `make check-protocol` and the isolated regression test: the regenerated -# worktree bytes must match the staged (index) state exactly, and no new -# untracked output may appear under the artifact paths. -set -eu - -git diff --exit-code -- protocol/protocol.gen.go frontend/src/generated/protocol - -untracked=$(git ls-files --others --exclude-standard -- protocol/protocol.gen.go frontend/src/generated/protocol) -if [ -n "$untracked" ]; then - echo "untracked generated protocol artifacts:" >&2 - printf '%s\n' "$untracked" >&2 - exit 1 -fi diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 03236758..0e7fb0b2 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -535,4 +535,64 @@ assert_equal_file "$spaced_prefix/bin/lightcode" "make-fixture-binary" assert_mode "$spaced_prefix/bin/lightcode" 755 assert_no_staging_residue "$spaced_prefix/bin" +# Captured Wails argv: the real root Makefile build and install recipes run +# through a fake Wails CLI planted at the fixture HOME, which records its +# arguments. No companion file is used here, so these calls exercise the +# actual recipes, including the tagged build command and the unchanged +# version-stamping flags. The record is cleared immediately before each target +# so every assertion proves that target's own Wails call, never a previous one. +make_wails_argv=$test_root/make-wails.argv +make_wails_expected=$test_root/make-wails.expected +mkdir -p "$make_home/go/bin" +cat >"$make_home/go/bin/wails" <"$make_wails_argv" +EOF +chmod 755 "$make_home/go/bin/wails" + +cat >"$make_wails_expected" <<'EOF' +build +-tags +webkit2_41 +-ldflags +-X github.com/MMinasyan/lightcode/internal/version.Version=v1.2.3 -X github.com/MMinasyan/lightcode/internal/version.Commit=abc1234 +EOF + +run_make_real() { + out=$1 + shift + ( + PATH="$shim_dir:$PATH" + HOME="$make_home" + export PATH HOME + cd "$make_source_tree" + make -f "$root_dir/Makefile" "$@" + ) >"$out" 2>&1 +} + +run_make_real_expect_success() { + out=$1 + shift + if ! run_make_real "$out" "$@"; then + cat "$out" >&2 || true + fail "expected real make target success" + fi +} + +assert_wails_argv() { + [ -f "$make_wails_argv" ] || fail "Wails CLI was not invoked for this target" + assert_equal_file "$make_wails_argv" "$(cat "$make_wails_expected")" +} + +make_real_prefix=$test_root/make-real-prefix +rm -f "$make_wails_argv" +run_make_real_expect_success "$test_root/make-real-build.out" VERSION=v1.2.3 COMMIT=abc1234 build +assert_wails_argv +rm -f "$make_wails_argv" +run_make_real_expect_success "$test_root/make-real-install.out" VERSION=v1.2.3 COMMIT=abc1234 PREFIX="$make_real_prefix" install +assert_wails_argv +assert_equal_file "$make_real_prefix/bin/lightcode" "make-fixture-binary" +assert_mode "$make_real_prefix/bin/lightcode" 755 +assert_no_staging_residue "$make_real_prefix/bin" + printf 'installer tests passed\n'