diff --git a/README.md b/README.md
index c8d34351..5dc2eb73 100644
--- a/README.md
+++ b/README.md
@@ -498,3 +498,25 @@ See [docs/language_write_cost_benchmark.md](docs/language_write_cost_benchmark.m
## License
HowlFrame is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
+
+## Browser host capability grants
+
+Compiled JavaScript network access requires an explicit host grant. In Node,
+set `HOWLFRAME_ALLOW_CAPS=network` in the environment. In a browser, the hosting
+page sets `globalThis.HOWLFRAME_ALLOW_CAPS = "network"` before loading the
+compiled script. Unset, empty, non-string, and unrelated grants deny a request
+before calling fetch. The Node environment remains authoritative in Node.
+
+```html
+
+
+```
+
+The string uses the same capability names as `-allow-caps`; only the network
+helper has a browser host API. Node-specific filesystem, process, and environment
+APIs do not become browser APIs. This live value is read at call time, so any
+script on the page can change it. It is host configuration, not an OS sandbox
+or deployment authorization. A process.env bundler shim takes precedence and
+fails closed unless that shim supplies the environment grant.
+Browser bootstrap failures keep their original error rather than trying to
+call Node process.exit.
diff --git a/change_log.md b/change_log.md
index ab0aeb7c..88be185e 100644
--- a/change_log.md
+++ b/change_log.md
@@ -8,6 +8,7 @@
* Dogfood round 2: `apps/notes_cli`, `apps/auth_api`, `apps/grep_cli` built on standalone bytecode; `store_open` accepts a runtime URI expression; AST density sugar `(switch …)` and `(inc x)`. Real-app token harness extended with Python/Go/Node ports and parity. Journal: `docs/journals/2026-10-10_dogfood_apps_round2.md`.
### Fixed
+* HOWL-E2E-UX-001 UX-003: browser JavaScript fetch accepts explicit host grants without reading Node process.env. Missing grants still deny before requests; Node environment authority and exit behavior are preserved. Browser bootstrap errors preserve the original failure.
* S8/P2.1 filesystem portion: native VM and interpreter enforce repeatable `filesystem:read=` and `filesystem:write=` grants, with symlink containment checks. File stores require read coverage and write coverage for mutations. Coarse `filesystem` remains unrestricted; process/network scoping and generated backends remain open. Journal: `docs/journals/2026-10-06_path_scoped_filesystem.md`.
* Review C3/C7: executable VM effect-gate conformance coverage; demo decisions use escaped JSON; executor capability checks precede writes. Effects remain ordered, not atomic. Journal: `docs/journals/2026-10-05_effect_gate_conformance_demo_honesty.md`.
* Checker enforces `docs/reference/NUMERIC_CONTRACT.md`: mixed int/float `+`, `-`, `*` are valid and typed float, `/` always types float, and int/float comparisons are valid (HFREC-063). Void expressions in value position (`let`, `set`, call/print/list arguments, `append`/`map_set` values) and statically provable builtin argument mismatches (`str_split`, `str_join`, `regex_match`, `list_len`) are rejected before any backend runs (HFREC-009, HFREC-010). `parse_json` bodies must be variable names outside `web_app` (HFREC-004). Generated JavaScript keeps typed parameter names (HFREC-049). Go and JavaScript `to_int` truncate toward zero like the VM; `web_app` rejects integer literals and JSON integers outside +/-(2^53-1) instead of rounding. Wasm integer `/` fails closed. Artifact format version 2 (sorted function table, deterministic bytes) still reads version 1 artifacts; version 1 readers cannot read version 2 artifacts (see `docs/reference/artifact_compatibility.md`). New corpus: `tests/parity_stabilization/`.
diff --git a/docs/journals/2026-10-12_browser_capability_dogfood.md b/docs/journals/2026-10-12_browser_capability_dogfood.md
new file mode 100644
index 00000000..baa57922
--- /dev/null
+++ b/docs/journals/2026-10-12_browser_capability_dogfood.md
@@ -0,0 +1,8 @@
+# Browser capability dogfood
+
+HowlBoard backend tests passed, but the browser emitted no API requests and displayed a connection error. The generated fetch grant helper referenced process.env in a browser.
+
+Before: five browser-like Node VM regressions failed with process undefined, including the explicit network-grant case.
+After: host browser grants are explicit strings; missing, invalid and unrelated grants deny before fetch. Node still reads its environment. The bootstrap catcher reports the original error in browser contexts.
+
+Test impact: existing Node fetch and other capability tests retain coverage. New generated-code tests execute with no process/require browser globals, covering allowed fetch and four denials. Full repository tests/build/vet and real HowlBoard browser acceptance are recorded in the mission evidence before publication.
diff --git a/internal/backend/javascript/browser_fetch_test.go b/internal/backend/javascript/browser_fetch_test.go
new file mode 100644
index 00000000..845c1019
--- /dev/null
+++ b/internal/backend/javascript/browser_fetch_test.go
@@ -0,0 +1,105 @@
+package javascript
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/howlcipher/howlframe/internal/checker"
+ "github.com/howlcipher/howlframe/internal/lexer"
+ "github.com/howlcipher/howlframe/internal/parser"
+)
+
+func TestBrowserFetchRequiresExplicitHostGrant(t *testing.T) {
+ root := parser.NewParser(lexer.NewLexer(`(web_app (print (bytes_to_string (fetch "https://example.invalid" "GET"))))`), "browser.howl").ParseExpression()
+ checker.Check(root)
+ code, _ := GenerateJSCode(root)
+ encoded, err := json.Marshal(code)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ name, grant string
+ allowed bool
+ }{
+ {"unset", "undefined", false},
+ {"empty", `""`, false},
+ {"wrong", `"filesystem"`, false},
+ {"invalid", "42", false},
+ {"network", `"network"`, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ // vm supplies browser globals without Node's process or require.
+ wrapper := `const vm = require("node:vm");
+let calls = 0;
+const logs = [];
+const browser = {
+ console: {log: value => logs.push(String(value)), error: () => {}},
+ fetch: async () => { calls++; return {text: async () => "browser response"}; }
+};
+const grant = ` + tc.grant + `;
+if (grant !== undefined) browser.HOWLFRAME_ALLOW_CAPS = grant;
+Promise.resolve(vm.runInNewContext(` + string(encoded) + `, browser)).then(
+ () => console.log(JSON.stringify({calls, logs, error: ""})),
+ err => console.log(JSON.stringify({calls, logs, error: String(err.message)}))
+);`
+ stdout, stderr, exit := runJS(t, wrapper)
+ if exit != 0 {
+ t.Fatalf("browser harness failed: %s %s", stdout, stderr)
+ }
+ var result struct {
+ Calls int `json:"calls"`
+ Logs []string `json:"logs"`
+ Error string `json:"error"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &result); err != nil {
+ t.Fatalf("invalid browser result %q: %v", stdout, err)
+ }
+ if tc.allowed {
+ if result.Calls != 1 || result.Error != "" || len(result.Logs) != 1 || result.Logs[0] != "browser response" {
+ t.Fatalf("granted fetch failed: %+v", result)
+ }
+ } else if result.Calls != 0 || len(result.Logs) != 0 || !strings.Contains(result.Error, "CAPABILITY_DENIED") {
+ t.Fatalf("fetch must fail closed before dialing: %+v", result)
+ }
+ })
+ }
+}
+
+func TestNodeEnvironmentCannotBeWidenedByBrowserGrant(t *testing.T) {
+ root := parser.NewParser(lexer.NewLexer(`(web_app (print (bytes_to_string (fetch "https://example.invalid" "GET"))))`), "node.howl").ParseExpression()
+ checker.Check(root)
+ code, _ := GenerateJSCode(root)
+ wrapper := `globalThis.HOWLFRAME_ALLOW_CAPS = "network";
+globalThis.fetch = () => { throw new Error("UNEXPECTED_REQUEST"); };
+` + code
+ stdout, stderr, exit := runJS(t, wrapper, "HOWLFRAME_ALLOW_CAPS=")
+ if exit == 0 || !strings.Contains(stderr, "CAPABILITY_DENIED") || strings.Contains(stdout+stderr, "UNEXPECTED_REQUEST") {
+ t.Fatalf("browser global widened Node authority: exit=%d stdout=%q stderr=%q", exit, stdout, stderr)
+ }
+}
+
+func TestBrowserBootstrapPreservesOriginalError(t *testing.T) {
+ root := parser.NewParser(lexer.NewLexer(`(web_app (print (bytes_to_string (fetch "https://example.invalid" "GET"))))`), "browser-error.howl").ParseExpression()
+ checker.Check(root)
+ code, _ := GenerateJSCode(root)
+ encoded, err := json.Marshal(code)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wrapper := `const vm = require("node:vm");
+const original = new Error("NETWORK_FAILED");
+const browser = {
+ HOWLFRAME_ALLOW_CAPS: "network",
+ console: {log: () => {}, error: () => {}},
+ fetch: async () => { throw original; }
+};
+Promise.resolve(vm.runInNewContext(` + string(encoded) + `, browser)).then(
+ () => { throw new Error("UNEXPECTED_SUCCESS"); },
+ err => { if (err !== original) throw new Error("ERROR_REPLACED"); console.log("original error preserved"); }
+);`
+ stdout, stderr, exit := runJS(t, wrapper)
+ if exit != 0 || strings.TrimSpace(stdout) != "original error preserved" {
+ t.Fatalf("bootstrap replaced the browser error: exit=%d stdout=%q stderr=%q", exit, stdout, stderr)
+ }
+}
diff --git a/internal/backend/javascript/javascript.go b/internal/backend/javascript/javascript.go
index 20aa2cf0..3fe08cc2 100644
--- a/internal/backend/javascript/javascript.go
+++ b/internal/backend/javascript/javascript.go
@@ -274,11 +274,18 @@ function howlRequestString(kind, value) {
`
}
-// grantJSHelper reads HOWLFRAME_ALLOW_CAPS, comma-separated, the same names
-// as -allow-caps. An empty or unset grant denies every name.
+// grantJSHelper reads the host's explicit grant. Node uses its environment;
+// browsers use a host-supplied global before loading the compiled script.
+// An empty, unset, or non-string grant denies every name.
func grantJSHelper() string {
return `function howlFrameGrantHas(name) {
- var raw = process.env.HOWLFRAME_ALLOW_CAPS || "";
+ var raw = "";
+ if (typeof process !== "undefined" && process.env) {
+ raw = process.env.HOWLFRAME_ALLOW_CAPS || "";
+ } else if (typeof globalThis !== "undefined") {
+ raw = globalThis.HOWLFRAME_ALLOW_CAPS;
+ }
+ if (typeof raw !== "string") return false;
var parts = raw.split(",");
for (var i = 0; i < parts.length; i++) {
if (parts[i].trim() === name) return true;
@@ -994,7 +1001,7 @@ func GenerateJSCode(node *ast.Node) (string, string) {
// The async IIFE would otherwise turn that into an unhandled rejection
// and a zero exit. try_let still catches the throw before it reaches
// this handler.
- code += fmt.Sprintf(";(async () => {\n%s\n})().catch((err) => {\n console.error(err && err.message ? err.message : err);\n process.exit(1);\n});\n", appCode)
+ code += fmt.Sprintf(";(async () => {\n%s\n})().catch((err) => {\n console.error(err && err.message ? err.message : err);\n if (typeof process !== \"undefined\" && typeof process.exit === \"function\") {\n process.exit(1);\n } else {\n throw err;\n }\n});\n", appCode)
} else {
code += fmt.Sprintf(";(async () => {\n%s\n})();\n", appCode)
}