From 3e40f4e9336b50450baefadfde112ee9f647d7ed Mon Sep 17 00:00:00 2001 From: howlcipher Date: Thu, 10 Sep 2026 09:01:50 -0400 Subject: [PATCH 1/4] feat(vm,checker): add store_keys, widen dict records, fail closed on handler panic Three changes forced by building HowlBoard, the reference application, on standalone bytecode. Each is independently testable and none is specific to that application. store_keys / STORE_KEYS enumerates a native store's record keys as a sorted list. The keys already existed in bcMemoryStore.records but were unreachable from .howl, so kv_cli, todo_cli, task_api, and HowlBoard each maintained a parallel index record that could silently diverge from the records it indexed. Enumeration is sorted because Go randomizes map iteration and callers list records for display. Dict values may now mix types. Dicts are the language's record literal, and both the VM and the native store carry map[string]any, so a record combining strings, ints, lists, and nested dicts already round-tripped correctly through store_put/store_get; only the analyzer rejected constructing one. Heterogeneous dict literals and map_set writes now widen the element type to any via the existing join helper. Key checks, target-kind checks, and list element homogeneity are unchanged. Two test assertions that encoded the old homogeneity rule are removed, with positive coverage added in their place. Route handlers now fail closed. A panic inside a handler wrote nothing to the ResponseWriter, so Go emitted 200 with an empty body, making a denied capability indistinguishable from a completed request. Handlers that fail before responding now return 500 carrying the structured VMError JSON, and the failure is logged to the VM error stream rather than process stdout. Handlers that already committed a response are left untouched. Verified: go build ./..., go vet ./..., go test ./... (32/32 packages), gofmt clean, plus -validate, -run, -compile-bc, -run-bc, -compile-wasm and TestRepoAnalystStandaloneBytecode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X427iD1w9dAGLML8wKn9aJ --- change_log.md | 21 +++++++ docs/reference/bytecode_reference.md | 1 + docs/reference/construct_coverage.md | 1 + internal/bytecode/bytecode.go | 9 +++ internal/bytecode/opcode.go | 2 + internal/capability/capability.go | 2 +- internal/checker/types.go | 29 +++++++++- internal/checker/types_test.go | 58 ++++++++++++++++++- internal/construct/construct.go | 1 + internal/vm/runtime_integrity_test.go | 54 ++++++++++++++++++ internal/vm/store_test.go | 69 +++++++++++++++++++++++ internal/vm/vm.go | 66 +++++++++++++++++++++- tools/orchestrator/orchestrator_schema.py | 6 ++ 13 files changed, 310 insertions(+), 9 deletions(-) diff --git a/change_log.md b/change_log.md index 7c67cea..560979f 100644 --- a/change_log.md +++ b/change_log.md @@ -3,6 +3,13 @@ ## Unreleased ### Added +* `store_keys` construct and `STORE_KEYS` bytecode instruction, returning every + record key in a native store as a sorted list. Go randomizes map iteration, so + enumeration is sorted to keep listing deterministic. Every prior HowlFrame + application (`kv_cli`, `todo_cli`, `task_api`, HowlBoard) had to maintain a + parallel index record that could silently diverge from the records it indexed; + `store_keys` removes that workaround. Requires the `database` capability, and + `filesystem` additionally for `file://` stores. * Runner-sealed, bounded negative map-state provenance for the internal direct HFIR experiment. It records completed map mutations and reads by backing-map identity, proves never-present versus effectively deleted keys, and fails @@ -65,6 +72,13 @@ diverge silently. ### Changed +* Dict values may now mix types. Dicts are the language's record literal, and the + VM and native store both carry `map[string]any`, so a record combining strings, + ints, lists, and nested dicts already executed correctly; only the analyzer + rejected it. Heterogeneous dict literals and `map_set` writes now widen the + element type to `any` through the existing `join` helper instead of reporting + `dict value N has type X, want Y`. Key checks, target-kind checks, and list + element homogeneity are unchanged. * Documented the existing standalone HTTP JSON request composition (`parse_json ... req.body` with `try_let`), its bounded scope, and the @@ -104,6 +118,13 @@ are classified separately and keep compiling unchanged. ### Fixed +* Route handlers fail closed. A panic inside an `http_server` route handler wrote + nothing to the `ResponseWriter`, so Go emitted `200` with an empty body and a + denied capability was indistinguishable from a completed request. Handlers that + fail before responding now return `500` with the structured `VMError` JSON + (preserving codes such as `CAPABILITY_DENIED` and `LIMIT_EXCEEDED`), and the + failure is reported on the VM's error stream rather than process stdout. A + handler that already committed a response is left untouched. * `SPAWN_AGENT` and `TASK` opcodes in the standalone bytecode VM now report a structured `UNSUPPORTED_CONSTRUCT` runtime error naming the opcode rather than panicking with `VM_INTERNAL` as an unknown opcode. Both opcodes are emitted diff --git a/docs/reference/bytecode_reference.md b/docs/reference/bytecode_reference.md index a98a9a3..8aaf521 100644 --- a/docs/reference/bytecode_reference.md +++ b/docs/reference/bytecode_reference.md @@ -55,6 +55,7 @@ | `STDERR` | | 1 | 0 | | Prints a value to standard error | | `STORE_DELETE` | string | 1 | 0 | database | Deletes a structured record by key | | `STORE_GET` | string | 1 | 1 | database | Fetches a structured record by key | +| `STORE_KEYS` | string | 0 | 1 | database | Returns every record key in a store, sorted | | `STORE_OPEN` | string, string | 0 | 0 | database | Creates or attaches a named in-memory store handle | | `STORE_PUT` | string | 2 | 0 | database | Upserts a structured record by key | | `STORE_VAR` | string | 1 | 0 | | Pops a value and stores it in a new variable | diff --git a/docs/reference/construct_coverage.md b/docs/reference/construct_coverage.md index 74d4768..4441ec3 100644 --- a/docs/reference/construct_coverage.md +++ b/docs/reference/construct_coverage.md @@ -82,6 +82,7 @@ | `stderr` | Yes | None | No | No | - | AUTHORITATIVE | | `store_delete` | Yes | database | No | No | - | AUTHORITATIVE | | `store_get` | Yes | database | No | No | - | AUTHORITATIVE | +| `store_keys` | Yes | database | No | No | - | AUTHORITATIVE | | `store_open` | Yes | database | No | No | - | AUTHORITATIVE | | `store_put` | Yes | database | No | No | - | AUTHORITATIVE | | `str_join` | Yes | None | No | No | - | AUTHORITATIVE | diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 32716c2..634c1ba 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -467,6 +467,15 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction { } insts = append(insts, c.compileNode(node.Children[2])...) insts = append(insts, BCInstruction{OpString: "STORE_DELETE", Op: OpStoreDelete, StringOperand: handleNode.Value}) + case "store_keys": + if len(node.Children) != 2 { + ast.ReportError("store_keys expects (store_keys handle)", node.Line, node.Column) + } + handleNode := node.Children[1] + if handleNode.Type != "SYMBOL" { + ast.ReportError("store_keys handle must be a symbol", handleNode.Line, handleNode.Column) + } + insts = append(insts, BCInstruction{OpString: "STORE_KEYS", Op: OpStoreKeys, StringOperand: handleNode.Value}) case "fetch": insts = append(insts, c.compileNode(node.Children[1])...) insts = append(insts, c.compileNode(node.Children[2])...) diff --git a/internal/bytecode/opcode.go b/internal/bytecode/opcode.go index 41a5e74..efb5fd6 100644 --- a/internal/bytecode/opcode.go +++ b/internal/bytecode/opcode.go @@ -70,6 +70,7 @@ const ( OpIsNil OpTimeNow OpEncodeJson + OpStoreKeys ) type OperandType string @@ -146,6 +147,7 @@ var Registry = map[Opcode]OpcodeSpec{ OpStorePut: {Code: OpStorePut, Name: "STORE_PUT", Operands: []OperandType{OperandString}, Pops: 2, Pushes: 0, Capability: capability.Database, Description: "Upserts a structured record by key"}, OpStoreGet: {Code: OpStoreGet, Name: "STORE_GET", Operands: []OperandType{OperandString}, Pops: 1, Pushes: 1, Capability: capability.Database, Description: "Fetches a structured record by key"}, OpStoreDelete: {Code: OpStoreDelete, Name: "STORE_DELETE", Operands: []OperandType{OperandString}, Pops: 1, Pushes: 0, Capability: capability.Database, Description: "Deletes a structured record by key"}, + OpStoreKeys: {Code: OpStoreKeys, Name: "STORE_KEYS", Operands: []OperandType{OperandString}, Pops: 0, Pushes: 1, Capability: capability.Database, Description: "Returns every record key in a store, sorted"}, OpReadLine: {Code: OpReadLine, Name: "READ_LINE", Operands: []OperandType{}, Pops: 0, Pushes: 1, Description: "Reads a line from standard input"}, OpStderr: {Code: OpStderr, Name: "STDERR", Operands: []OperandType{}, Pops: 1, Pushes: 0, Description: "Prints a value to standard error"}, OpExit: {Code: OpExit, Name: "EXIT", Operands: []OperandType{}, Pops: 1, Pushes: 0, Description: "Exits the process with a given status code"}, diff --git a/internal/capability/capability.go b/internal/capability/capability.go index 101f77b..1f68693 100644 --- a/internal/capability/capability.go +++ b/internal/capability/capability.go @@ -29,7 +29,7 @@ func All() []Capability { // If the construct does not require a capability, it returns None. func ForConstruct(name string) Capability { switch name { - case "db_connect", "sql_query", "store_open", "store_put", "store_get", "store_delete": + case "db_connect", "sql_query", "store_open", "store_put", "store_get", "store_delete", "store_keys": return Database case "fetch", "res", "res_json", "res_header", "http_res_header", "req_method", "http_req_method", "http_server_start", "http_route", "http_server_serve", "llm_generate", "achieve": return Network diff --git a/internal/checker/types.go b/internal/checker/types.go index 33c0fd0..86b2469 100644 --- a/internal/checker/types.go +++ b/internal/checker/types.go @@ -596,7 +596,12 @@ func (a *Analysis) inferList(node *ast.Node, env typeEnv) ast.TypeInfo { copy := value result.Element = © } else if result.Element != nil && known(value) && !compatible(*result.Element, value) { - a.add(node, fmt.Sprintf("dict value %d has type %s, want %s", index+1, typeName(value), typeName(*result.Element))) + // Dicts double as record literals, where mixed value types are + // the normal case rather than a mistake. The bytecode store and + // the VM both carry map[string]any, so widen the element type to + // any instead of rejecting a program the runtime executes fine. + widened := join(*result.Element, value) + result.Element = &widened } } } @@ -647,7 +652,9 @@ func (a *Analysis) inferList(node *ast.Node, env typeEnv) ast.TypeInfo { a.add(node, fmt.Sprintf("map_set key must be string, got %s", typeName(key))) } if dict.Element != nil && known(value) && !compatible(*dict.Element, value) { - a.add(node, fmt.Sprintf("map_set value has type %s, want %s", typeName(value), typeName(*dict.Element))) + // Same record-literal reasoning as the dict case: widen the target's + // element type to any rather than reject a mutation the VM accepts. + a.widenDictElement(node.Children[1], env, join(*dict.Element, value)) } return ast.Layout(ast.Void) case "map_delete": @@ -964,7 +971,7 @@ func isKeyword(name string) bool { "str_join", "regex_match", "confidence", "achieve", "fuzzy_cast", "time_now", "lazy_synthesize", "semantic_match", "neural_circuit", "ephemeral_circuit", "db_connect", "sql_query", "store_open", - "store_get", "store_put", "store_delete", "intent", + "store_get", "store_put", "store_delete", "store_keys", "intent", "optimize_signature", "schema_bridge", "cli_args", "rate_limit", "void", "string", "int", "float", "bool", "read_line", "stderr", "exit": return true @@ -1063,6 +1070,22 @@ func numeric(value ast.TypeInfo) bool { return value.Kind == ast.Int || value.Kind == ast.Float } +// widenDictElement relaxes the recorded element type of a dict-valued binding +// after a heterogeneous write. Each occurrence of a symbol is a distinct AST +// node, so updating the environment here keeps every later read of the same +// binding permissive instead of reporting a stale element type. +func (a *Analysis) widenDictElement(target *ast.Node, env typeEnv, widened ast.TypeInfo) { + if target == nil || target.Type != "SYMBOL" { + return + } + current, ok := env[target.Value] + if !ok || current.Kind != ast.Dict { + return + } + current.Element = &widened + env[target.Value] = current +} + func compatible(left, right ast.TypeInfo) bool { if !known(left) || !known(right) { return true diff --git a/internal/checker/types_test.go b/internal/checker/types_test.go index d10fe04..c6da981 100644 --- a/internal/checker/types_test.go +++ b/internal/checker/types_test.go @@ -351,7 +351,6 @@ func TestAnalyzeRejectsInconsistentAggregateLayouts(t *testing.T) { for _, expected := range []string{ "list element 2 has type string, want int", "list_get index must be int, got string", - "dict value 2 has type string, want int", "map_get key must be string, got int", } { found := false @@ -388,7 +387,6 @@ func TestAnalyzeRejectsInvalidAggregateMutationTypes(t *testing.T) { "append item has type int, want string", "map_set target must be dict, got list", "map_set key must be string, got int", - "map_set value has type int, want string", "append target must be list, got dict", } { found := false @@ -621,3 +619,59 @@ func TestCheckRouteHandlerValidation(t *testing.T) { }) } } + +// Dicts are the language's record literal. The VM and the native store both +// carry map[string]any, so a record mixing strings, ints, lists, and nested +// dicts executes correctly; the analyzer must widen the element type to any +// rather than reject a program the runtime accepts. +func TestAnalyzeWidensHeterogeneousDictValues(t *testing.T) { + root := parseTestProgram(t, `(cli_app + (let (mission (dict + ("id" "m1") + ("insertions" 7) + ("evidence" (list "ev-1")) + ("authority" (dict ("decision" "ALLOW"))))) + (do + (map_set mission "outcome" "task_completed") + (map_set mission "retries" 2) + (map_get mission "id"))))`) + + analysis := Analyze(root) + for _, diagnostic := range analysis.Diagnostics { + if strings.Contains(diagnostic.Reason, "dict value") || + strings.Contains(diagnostic.Reason, "map_set value") { + t.Errorf("unexpected homogeneity diagnostic: %q", diagnostic.Reason) + } + } +} + +// Widening must not silently disable the checks that still catch real +// mistakes: keys stay strings and the target still has to be a dict. +func TestAnalyzeStillRejectsDictKeyAndTargetErrors(t *testing.T) { + root := parseTestProgram(t, `(cli_app + (let (mission (dict ("id" "m1") ("count" 2))) + (do + (map_set mission 3 "three") + (map_get mission 4))))`) + + analysis := Analyze(root) + reasons := make([]string, 0, len(analysis.Diagnostics)) + for _, diagnostic := range analysis.Diagnostics { + reasons = append(reasons, diagnostic.Reason) + } + for _, expected := range []string{ + "map_set key must be string, got int", + "map_get key must be string, got int", + } { + found := false + for _, reason := range reasons { + if reason == expected { + found = true + break + } + } + if !found { + t.Errorf("missing diagnostic %q in %+v", expected, reasons) + } + } +} diff --git a/internal/construct/construct.go b/internal/construct/construct.go index 4318fe7..414f559 100644 --- a/internal/construct/construct.go +++ b/internal/construct/construct.go @@ -157,6 +157,7 @@ var table = []Entry{ {Name: "stderr", Support: Supported, Note: "STDERR"}, {Name: "store_delete", Support: Supported, Note: "STORE_DELETE"}, {Name: "store_get", Support: Supported, Note: "STORE_GET"}, + {Name: "store_keys", Support: Supported, Note: "STORE_KEYS"}, {Name: "store_open", Support: Supported, Opaque: true, Note: "STORE_OPEN; handle and URI are read as literals"}, {Name: "store_put", Support: Supported, Note: "STORE_PUT"}, {Name: "str_join", Support: Supported, Note: "STR_JOIN"}, diff --git a/internal/vm/runtime_integrity_test.go b/internal/vm/runtime_integrity_test.go index 10d75a9..39b243a 100644 --- a/internal/vm/runtime_integrity_test.go +++ b/internal/vm/runtime_integrity_test.go @@ -313,3 +313,57 @@ func TestHTTPFunctionContextIsRequestScoped(t *testing.T) { t.Fatalf("outside-context response helper panic = %#v, want RUNTIME_ERROR containing 'no response writer'", recovered) } } + +// A handler that panics must not reach the client as a success. Go sends 200 +// with an empty body when nothing is written, which previously made a denied +// capability indistinguishable from a completed request. +func TestHTTPHandlerFailuresFailClosed(t *testing.T) { + _, program := parseAndCompile(t, `(http_server 0 + (route "/denied" (lambda (req) (do (store_open kv "memory://x") (res_json 200 (dict ("ok" "yes")))))) + (route "/ok" (lambda (req) (res_json 200 (dict ("ok" "yes"))))))`) + + env := NewBcEnv(nil) + errOut := &bytes.Buffer{} + vm := &BCVM{ + prog: program, + env: env, + insts: program.Main, + stores: newBCStoreRegistry(), + Limits: DefaultLimits, + AllowedCaps: []capability.Capability{capability.Network}, + ErrOut: errOut, + } + // Register both routes without reaching HTTP_SERVER_SERVE, which blocks. + serve := len(program.Main) + for index, inst := range program.Main { + if inst.Op == bytecode.OpHttpServerServe { + serve = index + break + } + } + vm.run(program.Main[:serve], env) + mux := env.vars["__http_mux"].(*http.ServeMux) + + denied := httptest.NewRecorder() + mux.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/denied", nil)) + if denied.Code != http.StatusInternalServerError { + t.Fatalf("denied handler status = %d, want 500 (body %q)", denied.Code, denied.Body.String()) + } + var failure VMError + if err := json.Unmarshal(denied.Body.Bytes(), &failure); err != nil { + t.Fatalf("denied body = %q, want structured VMError JSON: %v", denied.Body.String(), err) + } + if failure.Code != "CAPABILITY_DENIED" { + t.Fatalf("denied code = %q, want CAPABILITY_DENIED", failure.Code) + } + if !strings.Contains(errOut.String(), "CAPABILITY_DENIED") { + t.Errorf("failure not reported on ErrOut: %q", errOut.String()) + } + + // A handler that succeeds must be completely unaffected. + ok := httptest.NewRecorder() + mux.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/ok", nil)) + if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), `"ok":"yes"`) { + t.Fatalf("healthy handler = %d %q, want 200 with body", ok.Code, ok.Body.String()) + } +} diff --git a/internal/vm/store_test.go b/internal/vm/store_test.go index c04ae69..3f04d60 100644 --- a/internal/vm/store_test.go +++ b/internal/vm/store_test.go @@ -117,3 +117,72 @@ func getStoreRecord(vm *BCVM, handle string, key string) map[string]any { }, vm.env) return vm.pop(bytecode.OpStoreGet).(map[string]any) } + +// store_keys is the enumeration primitive every prior HowlFrame application had +// to fake with a hand-maintained index record. Order must be sorted: Go +// randomizes map iteration, and callers list records for display. +func TestBytecodeStoreKeysSortedAndDeterministic(t *testing.T) { + vm := newStoreTestVM() + for _, key := range []string{"mission:c", "_seq", "mission:a", "mission:b"} { + putStoreRecord(vm, "kv", "memory://session", key, map[string]any{"n": key}) + } + + want := []any{"_seq", "mission:a", "mission:b", "mission:c"} + for attempt := 0; attempt < 8; attempt++ { + vm.run([]bytecode.BCInstruction{ + storeInstruction(bytecode.OpStoreKeys, "STORE_KEYS", "kv", ""), + }, vm.env) + got := vm.pop(bytecode.OpStoreKeys) + if !reflect.DeepEqual(got, want) { + t.Fatalf("attempt %d: STORE_KEYS = %#v, want %#v", attempt, got, want) + } + } +} + +func TestBytecodeStoreKeysReflectsDeletesAndEmptyStore(t *testing.T) { + vm := newStoreTestVM() + vm.run([]bytecode.BCInstruction{ + storeInstruction(bytecode.OpStoreOpen, "STORE_OPEN", "kv", "memory://session"), + storeInstruction(bytecode.OpStoreKeys, "STORE_KEYS", "kv", ""), + }, vm.env) + if got := vm.pop(bytecode.OpStoreKeys); !reflect.DeepEqual(got, []any{}) { + t.Fatalf("empty store STORE_KEYS = %#v, want empty list", got) + } + + putStoreRecord(vm, "kv", "memory://session", "task:1", map[string]any{"status": "open"}) + putStoreRecord(vm, "kv", "memory://session", "task:2", map[string]any{"status": "open"}) + vm.run([]bytecode.BCInstruction{ + {Op: bytecode.OpLoadConst, OpString: "LOAD_CONST", ValueOperand: "task:1"}, + storeInstruction(bytecode.OpStoreDelete, "STORE_DELETE", "kv", ""), + storeInstruction(bytecode.OpStoreKeys, "STORE_KEYS", "kv", ""), + }, vm.env) + if got := vm.pop(bytecode.OpStoreKeys); !reflect.DeepEqual(got, []any{"task:2"}) { + t.Fatalf("post-delete STORE_KEYS = %#v, want [task:2]", got) + } +} + +func TestBytecodeStoreKeysRequiresDatabaseCapability(t *testing.T) { + vm := newStoreTestVM() + putStoreRecord(vm, "kv", "memory://session", "task:1", map[string]any{"status": "open"}) + vm.AllowedCaps = []capability.Capability{capability.Filesystem} + + failure := func() (failure *VMError) { + defer func() { + if recovered := recover(); recovered != nil { + if vmErr, ok := recovered.(*VMError); ok { + failure = vmErr + return + } + panic(recovered) + } + }() + vm.run([]bytecode.BCInstruction{ + storeInstruction(bytecode.OpStoreKeys, "STORE_KEYS", "kv", ""), + }, vm.env) + return nil + }() + + if failure == nil || failure.Code != "CAPABILITY_DENIED" { + t.Fatalf("STORE_KEYS without database capability = %v, want CAPABILITY_DENIED", failure) + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index b3e4d11..6520412 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -20,6 +20,7 @@ import ( "os/exec" "reflect" "regexp" + "sort" "strconv" "strings" "sync" @@ -1644,6 +1645,23 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { } else { vm.push(record) } + case bytecode.OpStoreKeys: + store := vm.storeHandle(env, inst.StringOperand, inst.Op) + if store.file != "" { + vm.requireCapability(capability.Filesystem, inst.Op) + } + store.mu.RLock() + keys := make([]any, 0, len(store.records)) + for key := range store.records { + keys = append(keys, key) + } + store.mu.RUnlock() + // Go randomizes map iteration order. Sorting keeps enumeration + // deterministic, which every caller listing records depends on. + sort.Slice(keys, func(i, j int) bool { + return keys[i].(string) < keys[j].(string) + }) + vm.push(keys) case bytecode.OpStoreDelete: key := fmt.Sprint(vm.pop(inst.Op)) store := vm.storeHandle(env, inst.StringOperand, inst.Op) @@ -1840,16 +1858,40 @@ func (vm *BCVM) run(insts []bytecode.BCInstruction, env *BcEnv) any { prog := vm.prog mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + recorder := &httpResponseRecorder{ResponseWriter: w} reqEnv := NewBcEnv(capturedEnv) - reqEnv.vars["w"] = w + reqEnv.vars["w"] = http.ResponseWriter(recorder) reqEnv.vars[reqVar] = r reqEnv.vars["req"] = r childVM := &BCVM{prog: prog, env: reqEnv, stores: vm.stores, Limits: vm.Limits, AllowedCaps: vm.AllowedCaps, Out: vm.Out, ErrOut: vm.ErrOut} func() { defer func() { - if r := recover(); r != nil { - fmt.Println("HTTP Handler Panic:", r) + recovered := recover() + if recovered == nil { + return } + // A panicking handler must not reach the client as a + // success. Without an explicit response Go sends 200 + // with an empty body, which makes a CAPABILITY_DENIED + // denial indistinguishable from a completed request. + failure, ok := recovered.(*VMError) + if !ok { + failure = &VMError{ + Phase: "runtime", + Code: "HANDLER_PANIC", + Message: fmt.Sprint(recovered), + } + } + fmt.Fprintln(vm.ErrOut, "HTTP handler failure:", failure.Error()) + if recorder.wrote { + // The handler already committed a response; the + // status line cannot be rewritten, so surface the + // failure on the server side only. + return + } + recorder.Header().Set("Content-Type", "application/json") + recorder.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(recorder).Encode(failure) }() childVM.run(bodyInsts, reqEnv) }() @@ -2614,3 +2656,21 @@ func normalizeForJSON(v any) any { return v } } + +// httpResponseRecorder tracks whether a route handler committed a response. +// The VM needs this to tell a handler that answered from one that panicked +// before writing anything, so only the latter is converted into a 500. +type httpResponseRecorder struct { + http.ResponseWriter + wrote bool +} + +func (w *httpResponseRecorder) WriteHeader(status int) { + w.wrote = true + w.ResponseWriter.WriteHeader(status) +} + +func (w *httpResponseRecorder) Write(b []byte) (int, error) { + w.wrote = true + return w.ResponseWriter.Write(b) +} diff --git a/tools/orchestrator/orchestrator_schema.py b/tools/orchestrator/orchestrator_schema.py index 9d8b831..6738b44 100644 --- a/tools/orchestrator/orchestrator_schema.py +++ b/tools/orchestrator/orchestrator_schema.py @@ -274,6 +274,11 @@ class StoreGetInstruction(BaseModel): string_operand: str +class StoreKeysInstruction(BaseModel): + op: Literal["STORE_KEYS"] + string_operand: str + + class StoreOpenInstruction(BaseModel): op: Literal["STORE_OPEN"] string_operand: str @@ -377,6 +382,7 @@ class WriteFileInstruction(BaseModel): StderrInstruction, StoreDeleteInstruction, StoreGetInstruction, + StoreKeysInstruction, StoreOpenInstruction, StorePutInstruction, StoreVarInstruction, From cfa69287cf37125d10168ec676e7b63feaa8577c Mon Sep 17 00:00:00 2001 From: howlcipher Date: Thu, 10 Sep 2026 09:31:16 -0400 Subject: [PATCH 2/4] fix(js,gogen): repair web_app codegen found by building HowlBoard's interface Three defects and one gap, all surfaced by compiling a real web_app rather than a fixture. for over an expression silently miscompiled in both the JavaScript and Go backends. Each read ir.Kids[1].Value, which is empty for anything but a bound symbol, so (for m (map_get d "missions") ...) emitted "for (let m of )" and "for _, m := range {". Invalid output was produced with no diagnostic at all, which is the opposite of the fail-closed target contract. on_event emitted no trailing semicolon. Automatic semicolon insertion does not apply before "(", so a following top-level statement was parsed as a call of the addEventListener result, taking down the whole script. A web_app's top-level statements are now wrapped in an async IIFE. They routinely contain awaited calls and a classic