Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 4.7 6a404e479a fix(bus): clamp non-finite audioLevel rms to 0.0 at encode (#47)
`BusOutbound.audioLevel` carries a `Float rms` that `AudioLevelEmitter`
produces per audio frame (~100 Hz once the consumer is wired up).
Foundation's `JSONEncoder.NonConformingFloatEncodingStrategy` defaults
to `.throw`, so a single NaN — easy to land via a CoreAudio
over-/underflow or an all-zero buffer fed through certain RMS shapes —
would throw inside `WebviewBridge.sendRaw` and tear down the per-frame
bus path. The HUD-side consumer in `webview/packages/hud/src/bus/
client.ts:135` is currently a no-op (the ring shader is still on the
synthetic-sine animation from Plan 03-03), so the bug is LATENT, not
actively firing — but the clamp is cheap, defensive, and pins the
contract before the consumer activates.

Inline clamp at the encode site (`BusOutbound.encode(to:)` switch arm
for `.audioLevel`): replace non-finite floats with `0.0` via
`rms.isFinite ? rms : 0.0` immediately before `container.encode(_:)`.
No refactor of `AudioLevelEmitter` — the per-spec call here is
defensive at the serialization boundary, where every encoder-level
overflow path converges.

Two new unit tests in `CodableRoundTripTests`:
- `test_audioLevel_nanIsClampedToZero` — encodes `.audioLevel(rms: .nan)`,
  decodes, asserts `rms == 0.0` and `rms.isFinite`.
- `test_audioLevel_infinityIsClampedToZero` — companion for ±∞ so a
  future refactor cannot narrow the guard to NaN-only.

`swift test --package-path packages/Bus --filter CodableRoundTripTests`
passes (30 tests, +2 from this commit).

Closes #47

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:34:02 -05:00
james.maesandClaude Opus 4.7 ea8426eefc docs(voice): pin STT backend constant identity contract (#37)
The runtime mismatch flagged by #37 was already fixed at the call sites
in `App/AppDelegate.swift:1631-1642` and `:2562-2575` — both routes now
reference `STTBackendSelector.backendSpeechAnalyzer` /
`backendWhisperKit` (snake_case) instead of the pre-2026-05-12 camelCase
string literals. The regression test
`STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake`
already locks both halves of the contract:

  1. The snake_case constants round-trip through `make(backend:)` without
     hitting the unknown-backend default branch.
  2. The pre-fix camelCase shape (`"whisperKit"`) DOES land in the
     default branch and DOES emit an unknown-backend warning.

So the #37 close action is just to make that contract self-documenting
at the declaration site rather than relying on readers finding the test.
Add a doc-comment to the two `public static let backend*` constants that
explains the wire shape, names the regression test, references Issues
#29 + #37, and warns against renaming without updating `PerTurnSnapshot`
and the test in lockstep.

No code change; `swift test --package-path packages/Voice` continues to
pass (26 tests).

Closes #37

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:33:48 -05:00
19 changed files with 75 additions and 1541 deletions

No files matched your search

-62
View File
@@ -1,62 +0,0 @@
# OpenSSF Scorecard — security supply-chain posture for KofTwentyTwo/Jarvis.
#
# Issue #178. Scorecard scores 18 security checks (Branch-Protection, Code-Review,
# Pinned-Dependencies, SAST, Token-Permissions, etc.) and publishes results as
# SARIF to GitHub code-scanning. A weekly schedule keeps the score fresh; we
# also re-run on every push to develop so significant posture regressions show
# up promptly. The badge in README.md links back to the public scorecard page.
#
# Permissions are the canonical Scorecard set:
# - id-token: write OIDC for publish-results
# - contents: read read repo tree
# - security-events: write upload SARIF to code-scanning
#
# This workflow runs on github-hosted ubuntu-latest. Self-hosted Grogu is not
# used here -- Scorecard is short, network-bound, and benefits from a clean
# ephemeral environment.
#
# Workflow consumes no untrusted user inputs (no github.event.* interpolation
# in run: blocks), so it is safe from workflow-injection patterns.
name: OpenSSF Scorecard
on:
schedule:
# Mondays at 06:00 UTC. Off-peak relative to weekday push spikes.
- cron: "0 6 * * 1"
push:
branches: [develop]
workflow_dispatch:
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@v2.4.0
with:
results_file: results.sarif
results_format: sarif
# publish_results: true uploads anonymized scores to the OpenSSF
# public dataset, which is what powers the README badge URL
# (api.scorecard.dev). Safe for a public repo; would need
# reconsidering if the repo were ever made private.
publish_results: true
- name: Upload SARIF to code-scanning
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- name: Upload SARIF (when present)
if: hashFiles('semgrep.sarif') != ''
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
category: semgrep
+4 -5
View File
@@ -740,11 +740,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 4.5 Bus wiring — construct the `WebviewBridge` around the HUD's
// `WKWebView`, install the `WKUserScript` at document-start in
// the page world (the shared `WKContentWorld.page` used by
// Injection.js and the HUD bundle alike since `fb41c5f`),
// start the single-writer `HudStateCoordinator`, and load the
// R3F bundle's `index.html` so the handshake kicks off. See
// `installBus` for the seven-step sequence.
// the isolated `JarvisBusWorld`, start the single-writer
// `HudStateCoordinator`, and load the R3F bundle's
// `index.html` so the handshake kicks off. See `installBus`
// for the seven-step sequence.
installBus()
// 5. Keychain probe. Banner suppression: when the API key is
-1
View File
@@ -209,7 +209,6 @@ bash scripts/check-corpus-secrets.sh
bash scripts/check-embedding-dim-literal.sh
bash scripts/check-install-order.sh
bash scripts/check-no-evaluate-javascript.sh
bash scripts/check-no-jarvis-bus-world-references.sh
bash scripts/check-no-leftover-stubs.sh
bash scripts/check-no-modal-presentation.sh
bash scripts/check-no-null-voice-adapters.sh
-1
View File
@@ -8,7 +8,6 @@
[![Issues](https://img.shields.io/github/issues/KofTwentyTwo/Jarvis)](https://github.com/KofTwentyTwo/Jarvis/issues)
[![Platform: macOS 26](https://img.shields.io/badge/platform-macOS%2026%20Tahoe-blue)](https://www.apple.com/macos/)
[![Swift 6](https://img.shields.io/badge/swift-6.0-orange)](https://swift.org)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/KofTwentyTwo/Jarvis/badge)](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/Jarvis)
Jarvis is a hybrid native + embedded-web macOS app that runs as an ambient
menu-bar presence. A Swift host owns the operating system (mic, camera,
File diff suppressed because it is too large. Load diff
@@ -1,151 +0,0 @@
# Local-first LLM routing — design
**Status:** draft 2026-05-14, awaiting user approval
**Authors:** James + Claude (Opus 4.7)
**Scope:** flip the chat-turn provider default from Anthropic to local Ollama, with reactive escalation to Claude Opus on detectable Ollama failure.
## Goal
Default every conversation turn to local Ollama (`qwen2.5-coder:32b-instruct-q8_0`). Escalate to Anthropic Claude Opus 4.7 only when local fails in a way the runtime can detect. The user sees one coherent reply per turn, with a small badge if escalation fired.
## Why now
The codebase is already structurally ready:
- `LLMProvider` protocol is symmetric between Anthropic + Ollama (same `AsyncThrowingStream<LLMEvent>` shape, same `ToolChoice.none` semantics).
- `AgentOrchestrator` is already config-driven via `providerFactory(perTurn.resolvedProvider)` (line 311).
- A mid-conversation escalation hook already exists — line 793 retries on `streamTruncated` by calling `providerFactory(updated.resolvedProvider)`.
- Memory extraction is wired to Ollama separately and is not affected by chat-turn routing.
This is mostly a policy + UX change, not a new abstraction.
## Approach
Reactive fallback only. No predictive routing, no router-model classifier, no self-reported escalation tool. The orchestrator tries Ollama first; if it fails in one of an enumerated set of detectable ways, it retries the turn once on Anthropic, emits an escalation event for the HUD, and surfaces the answer to the user.
User picked this approach because:
1. The existing escalation hook is reactive — extending what's there beats inventing a new layer.
2. Reactive fallback produces evidence — every escalation is a real-world data point for any future predictive routing.
3. Zero added latency on the happy path; no extra LLM call to classify the turn.
## Spec
### 1. Default provider flip
- `default-config.json:9` changes from `"anthropic"` to `"ollama"`.
- `PerTurnSnapshot.provider` already exists as the policy carrier.
- Ollama target model is `qwen2.5-coder:32b-instruct-q8_0` (the tool-calling baseline pinned by `ModelID.qwen25coder32b`).
- Anthropic remains a first-class provider and remains selectable.
### 2. Escalation trigger taxonomy
The runtime fires escalation on, and only on, these detectable failure modes (closed enum `OllamaFailureKind`):
| Trigger | Detection |
|---|---|
| `streamTruncated` | Existing — already wired at `AgentOrchestrator.swift:793`. |
| `malformedToolCall` | `tool_calls` payload fails to decode against `ToolUseRequest`. Known Qwen 3.x footgun per CLAUDE.md. |
| `unknownTool` | Model invokes a tool name not in the registry. |
| `refusal` | `stopReason: .refusal` event arrives. |
| `connectionFailure` | URLSession error before any tokens received (Ollama down, port refused). |
| `emptyResponse` | `messageStop` with zero `textDelta` and zero `toolUseRequested`. |
Explicit non-triggers (do not escalate):
- Partial answer that "feels weak"
- Semantic hallucinations the runtime can't detect
- Tool that returned an error (tool failure ≠ model failure)
- Slow streaming (no SLA-based escalation)
These are quality problems the runtime cannot judge. Out of scope for this design.
### 3. Escalation contract
```swift
struct EscalationDecision: Sendable, Codable {
let from: ProviderSelection // .ollama
let to: ProviderSelection // .anthropic
let reason: OllamaFailureKind
let firedAt: Date
}
```
`AgentOrchestrator` enforces **at most one escalation per turn**. If the post-escalation Anthropic call also fails, the orchestrator surfaces the error to the user — it does not fall back further. (A second failure means the cloud provider is also down; pretending otherwise is dishonest.)
### 4. Bus event for the HUD badge
New outbound event:
```swift
case escalated(EscalationDecision)
```
Emitted once when fallback fires. HUD renders an unobtrusive badge inline with the affected message: "Escalated to Opus — Ollama returned malformed tool call" (or whichever `OllamaFailureKind` fired). The badge stays with that specific message; subsequent messages are visually clean.
The badge is HUD-side only — it does not affect the chat message content, persistence, or replay log shape.
### 5. Per-turn vs sticky
**Per-turn.** Next user message starts fresh on Ollama. Reasoning: one Ollama failure on a Python question doesn't mean the next "what time is it" should go to Opus.
User can manually pin a conversation to Anthropic via Settings. Manual pin overrides escalation logic; in that mode, no escalation badges fire because no escalation can fire.
### 6. Settings UX
Settings → Provider section gets one new control:
- **Provider preference:** `Local-first (recommended)` / `Anthropic only` / `Ollama only`
- `Local-first` is the new default; corresponds to `provider: "ollama"` plus escalation enabled.
- `Anthropic only` pins to Anthropic, escalation moot.
- `Ollama only` pins to Ollama AND disables escalation — Ollama failures surface as errors. Useful for hard-offline / privacy-strict mode.
The existing `default-config.json` `provider:` field continues to drive the runtime; new logic decides whether escalation is wired based on a sibling `escalationEnabled` field that defaults to `true` only when `provider == "ollama"`.
### 7. Testing
Three eval lanes added to the Harness package:
- **Reactive fallback fires correctly** — fixture: Ollama returns `malformedToolCall`; assert orchestrator switches to Anthropic mid-turn AND emits one `escalated` bus event.
- **Escalation budget honored** — fixture: Ollama fails AND Anthropic also fails on the same turn; assert orchestrator surfaces a structured error, does not loop, emits exactly one `escalated` event.
- **Pinned mode disables escalation** — config: `provider=ollama, escalationEnabled=false` + Ollama failure → surface error directly, zero `escalated` events.
No new fixture corpus needed; leverages existing `FixtureKind` cases in `MockLLMProvider`.
### 8. Non-goals
- No predictive routing.
- No "stream Anthropic continuing Ollama's partial output" — clean restart only.
- No automatic re-Ollama after Anthropic finishes — once escalated, the rest of the turn stays on Anthropic.
- No memory extraction changes; that pipeline is independent and stays on Ollama.
- No changes to non-chat surfaces (TTS, MCP, voice loop) — they have their own provider stories.
- No new GitHub issue label or milestone; this is a v0.5+ migration item that the user wants brought forward.
### 9. Effort estimate
Single PR, ~3-4 hours:
| Subtask | Estimate |
|---|---|
| `default-config.json` flip + Settings UI control | 30 min |
| Orchestrator escalation logic extension + budget tracking | 60 min |
| Bus event + HUD badge component | 60 min |
| Harness tests (3 lanes) | 60 min |
| Docs + comments | 15 min |
## Success criteria
- Default fresh-launch experience uses Ollama for chat turns.
- A turn that fails on Ollama via any `OllamaFailureKind` automatically retries on Anthropic and the user sees a single coherent reply with a badge.
- A turn that fails on both providers shows a structured error, not an infinite retry.
- A user with `Ollama only` mode sees the Ollama error directly with no escalation.
- All existing tests pass; three new escalation tests pass.
- Memory extraction is unaffected.
- The chat surface still streams tokens at acceptable latency (no perceptible regression).
## Open questions for the user
None blocking — design is concrete enough to implement. If anything below is wrong, push back before the spec is committed.
- Should the badge wording differ per `OllamaFailureKind`, or just say "Escalated to Opus"? Current design: differentiate, because the reason is small additional information that helps the user understand when Ollama is struggling.
- Should the orchestrator emit a single `bus.escalated` per turn, or stream per-attempt diagnostics? Current design: one per turn, keeps the bus contract simple.
@@ -1,7 +1,6 @@
// Injected at document-start in the page world (the default
// `WKContentWorld.page` since `fb41c5f` retired the prior isolated named
// world; see WebviewBridge.swift). Installs window.jarvisBus with the
// minimum surface needed to complete the handshake.
// Injected at document-start in WKContentWorld("JarvisBusWorld").
// Installs window.jarvisBus with the minimum surface needed to complete
// the handshake.
//
// IMPORTANT — protocolVersion drift hazard: the full TS bundle's installer
// (Vy() in the minified output) shape-checks window.jarvisBus and SKIPS
+2 -3
View File
@@ -6,9 +6,8 @@ import JarvisLogging
/// Main-actor-bound bridge between Swift and the webview's JS layer.
///
/// Responsibilities:
/// - Install a `WKScriptMessageHandlerWithReply` on the page world
/// (`WKContentWorld.page`, the default since `fb41c5f`) so page scripts
/// in the loaded HUD bundle can address it via `window.webkit.messageHandlers.jarvisBus`.
/// - Install a `WKScriptMessageHandlerWithReply` in an isolated content world
/// (`JarvisBusWorld`) so page scripts cannot observe or post to it.
/// - Inject a `WKUserScript` at `.atDocumentStart` in the same world so
/// `window.jarvisBus` exists before any page script runs (pitfall 3).
/// - Decode inbound JSON into `BusInbound` and either (a) handle the handshake
@@ -15,7 +15,7 @@ import WebKit
/// **Why this exists** — Phase D-Phase-2 / Phase F1 of
/// `.planning/AUDIT-AND-FIX-PLAN.md`. The 2026-05-02 audit-and-fix
/// session uncovered a `WKContentWorld` isolation bug
/// (`Injection.js` ran in a prior isolated named content world
/// (`Injection.js` ran in `WKContentWorld(name: "JarvisBusWorld")`
/// while the bundle ran in the page world; `window.jarvisBus`
/// properties were per-world isolated; bundle's `attachBus()` saw
/// `undefined` and created a SECOND fresh bus object; two stubs, two
@@ -127,13 +127,13 @@ final class RealWKWebViewIntegrationTests: XCTestCase {
/// **The test that would have caught the WKContentWorld bug.**
///
/// On the bug branch, `Injection.js` ran in the prior named
/// content world while `WebviewBridge.sendRaw` invoked
/// `callAsyncJavaScript` against the same named world — but the
/// message *handler* was registered against `WKContentWorld.page`,
/// so JS-side `postMessage` calls from the named-world stub never
/// reached Swift. Handshake hung at `.sentHello`, eventually timed
/// out with an `NSAlert`.
/// On the bug branch, `Injection.js` ran in
/// `WKContentWorld(name: "JarvisBusWorld")` while
/// `WebviewBridge.sendRaw` invoked `callAsyncJavaScript` against
/// the same named world — but the message *handler* was registered
/// against `WKContentWorld.page`, so JS-side `postMessage` calls
/// from the named-world stub never reached Swift. Handshake hung
/// at `.sentHello`, eventually timed out with an `NSAlert`.
///
/// On the fixed branch (fb41c5f), Injection.js auto-acks `hello`
/// at the stub layer in the unified `.page` world, the bridge's
@@ -78,8 +78,8 @@ final class WebviewBridgeOutboundTests: XCTestCase {
.decode(BusOutbound.self, from: Data((payload ?? "").utf8))
XCTAssertEqual(decoded, .hudState(.idle))
// F-A2-01: production WebviewBridge uses `WKContentWorld.page` (the
// default since `fb41c5f`); the prior named-world assertion was
// retired in that commit but this test wasn't updated until #45.
// default since `fb41c5f`); the prior `JarvisBusWorld` named-world
// assertion was retired in that commit but this test wasn't updated.
XCTAssertEqual(call.contentWorld, .page)
}
@@ -160,14 +160,11 @@ final class WebviewBridgeTests: XCTestCase {
// Re-adding a handler with the same (contentWorld, name) pair would
// raise `NSInvalidArgumentException`. The constructor succeeding is
// itself evidence of registration. As a double-check, `remove` on a
// registered handler must succeed without throwing. We remove from
// `WKContentWorld.page` because that is the default world
// `WebviewBridge.init` registers the handler against since `fb41c5f`
// unified bus and bundle into the same page world.
// registered handler must succeed without throwing.
let controller = webView.configuration.userContentController
controller.removeScriptMessageHandler(
forName: "jarvisBus",
contentWorld: .page
contentWorld: WKContentWorld.world(name: "JarvisBusWorld")
)
// No XCTAssert needed — the remove call succeeding on a registered
// handler is the assertion.
@@ -12,7 +12,7 @@ import Replay
/// Test seam over `Replay.ReplayLog`. The Memory module deliberately does NOT
/// depend on a concrete `ReplayLog` instance — `AppDelegate.installMemory`
/// hands a wrapper through `setReplayLog` at startup. Tests inject a
/// (Plan 07-06) hands a wrapper through `setReplayLog`. Tests inject a
/// mock conforming to this protocol.
public protocol MemoryReplaySink: Sendable {
func record(_ event: ReplayEvent, forTriggerTurnId triggerTurnId: Int64)
@@ -34,10 +34,8 @@ public protocol MemoryReplaySink: Sendable {
/// memory store the app refuses to launch. This eliminates the silent
/// dormancy that hid B-02 / B-04-class substrate failures.
///
/// The actor is the only writer. The bounded extraction channel writes
/// here via `applyOp`; the hybrid (FTS5 + vec0) search path reads through
/// `MemoryReadStore` conformance and emits `ReplayEvent.memoryRetrieval`
/// via `recordRetrieval`.
/// The actor is the only writer. Plan 07-02 wires the bounded extraction
/// channel; Plan 07-03 wires the search path + ReplayEvent emission.
public actor MemoryStore {
public static let logChannel = "memory"
@@ -333,11 +331,11 @@ public actor MemoryStore {
sink.record(.memoryMutation(bytes), forTriggerTurnId: triggerTurnId)
}
// MARK: - Read path (MEM-07 + TEXT-03 + D-02 + D-05)
// MARK: - Read path (Plan 07-03 — MEM-07 + TEXT-03 + D-02 + D-05)
/// SOLE production emission site for ReplayEvent.memoryRetrieval (D-05).
/// Mirrors `recordMemoryMutation` on the write path. Best-effort; if no
/// replay sink has been injected, the call is a silent no-op.
/// Mirrors recordMemoryMutation from 07-02. Best-effort; if no replay
/// sink has been injected, the call is a silent no-op.
public func recordRetrieval(_ ref: FactRef, triggerTurnId: Int64) {
guard let sink = replayLog else { return }
let stamp: Int64 = ref.timestamp == 0
@@ -540,13 +538,13 @@ public actor MemoryStore {
}
}
// MARK: - HybridSearch + SessionHistory conformance
// MARK: - HybridSearch + SessionHistory conformance (Plan 07-03 Task 2)
extension MemoryStore: MemoryReadStore {}
extension MemoryStore: SessionHistoryReading {}
extension OllamaEmbeddingClient: EmbeddingProviding {}
// MARK: - Regression corpus test seams
// MARK: - Plan 07-06 / Regression corpus test seams
//
// These small read-only seams are exercised exclusively by the env-gated
// MemoryRegressionCorpusTests suite (JARVIS_REAL_MODELS=1). They wrap raw
-11
View File
@@ -77,16 +77,6 @@ pnpm --filter @jarvis/hud build
DEST="$REPO_ROOT/App/Resources/webview"
mkdir -p "$DEST"
mkdir -p "$DEST/assets"
# Issue #46: Vite emits content-hashed asset names (e.g. `index-CXPb0I5z.js`),
# so the active bundle filename changes on every dependency or source edit.
# Without explicit cleanup, stale chunks accumulate in `assets/` because we
# don't pass `--delete` to rsync below. Sweep the asset glob (JS + maps + CSS)
# before the rsync drops the fresh bundle. `bus-harness.html`, `index.html`,
# and the directory's `.gitignore` live one level up (in $DEST), not in
# $DEST/assets, so they are not at risk from this rm.
rm -f "$DEST"/assets/*.js "$DEST"/assets/*.js.map "$DEST"/assets/*.css "$DEST"/assets/*.css.map
echo "[build-webview] rsync dist/ -> $DEST"
# --exclude bus-harness.html: keep 02-03's committed copy intact (parity
@@ -95,7 +85,6 @@ echo "[build-webview] rsync dist/ -> $DEST"
# --exclude .gitignore: preserve the App/Resources/webview/.gitignore that
# lets index.html stay source-committed while assets/ regenerates.
# We do NOT pass --delete: other vendored assets coexist in this dir.
# (The explicit `rm -f` above handles stale `assets/` chunks; see #46.)
rsync -a \
--exclude=bus-harness.html \
--exclude=.gitignore \
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
#
# check-no-jarvis-bus-world-references.sh
#
# Issue #45 boundary gate.
#
# The named `WKContentWorld("JarvisBusWorld")` was retired in `fb41c5f` —
# the bus message handler and `Injection.js` user script now both register
# against `WKContentWorld.page` (the default page world). Old doc-comments
# and test fixtures hung onto the name for months afterward; #45 closed
# the cleanup.
#
# This gate prevents new code or comments from re-introducing the literal
# string `JarvisBusWorld` outside the historical reference corpora:
#
# - `.planning/` archived plans / audit reports / handoffs
# - `docs/` spec documents that explain the migration
# - `node_modules/` vendored deps (out of repo control anyway)
# - `.build/`, build/ SwiftPM + Xcode derived data
# - `webview/packages/*/dist/` TypeScript build output (regenerated from src)
# - this script itself we have to reference the literal to grep for it
# - `.git/` git internals
#
# Fails CI if any other tracked file contains the literal string.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$REPO_ROOT"
# Use `git grep` so we automatically respect .gitignore (so dist/ etc. that
# aren't tracked don't trip the gate even if they exist on disk locally).
# `--cached` would miss new working-tree files; default mode greps the
# working tree filtered by tracked-or-untracked-not-ignored, which is what
# we want before a commit.
matches=$(git grep --no-color -n "JarvisBusWorld" -- \
':!.planning/' \
':!docs/' \
':!scripts/check-no-jarvis-bus-world-references.sh' \
':!webview/packages/*/dist/' \
|| true)
if [ -n "$matches" ]; then
echo "[check-no-jarvis-bus-world-references] FAIL: tracked source still references the retired 'JarvisBusWorld' named content world." >&2
echo "" >&2
echo "$matches" >&2
echo "" >&2
echo "[check-no-jarvis-bus-world-references] Rewrite these to 'page world' / 'shared world' / 'defensive double-install guard' — see #45 and the JSDoc in webview/packages/bus/src/bridge.ts for the canonical phrasing." >&2
exit 1
fi
echo "[check-no-jarvis-bus-world-references] PASS (no tracked source references the retired named world)"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@jarvis/bus",
"private": true,
"version": "0.0.1",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+23 -25
View File
@@ -57,28 +57,27 @@ export interface InstallOptions {
* - `send(msg)` JSON-encodes and posts to `webkit.messageHandlers.jarvisBus`.
* Callers await the returned Promise to observe Swift's reply.
*
* ## Defensive double-install guard (H-02)
* ## WKContentWorld attach-to-existing path (H-02)
*
* Both `Injection.js` (a `WKUserScript` injected at `.atDocumentStart`) and
* this bundle's `installJarvisBus()` run in the SAME page world — the
* default `WKContentWorld.page` since `fb41c5f` retired the prior
* isolated named world. Injection.js fires first and stamps a
* minimal `window.jarvisBus` with `protocolVersion`, `_handler`, plus the
* `send` / `receive` / `onOutbound` surface needed for the handshake.
* The bundle's installer fires later (top-level side-effect of `main.tsx`),
* and if we naively overwrote `window.jarvisBus` we would orphan
* Injection.js's queued early messages and any handshake state already
* captured against the original closure.
* When the HUD bundle runs in the page's default JS world (via
* `<script type="module" src="…">`), `webkit.messageHandlers.jarvisBus` is
* NOT visible — Swift registered the handler `in: JarvisBusWorld` and
* WebKit isolates message handlers per content world. However,
* `window.jarvisBus` is a property on the (cross-world-shared) `window`
* object, so the bus instance installed by `Injection.js` inside
* `JarvisBusWorld` IS observable from the default world.
*
* The shape check below (`isJarvisBusShape`) is therefore a defensive
* double-install guard inside one page world, not a cross-world attach.
* When we detect a pre-existing `window.jarvisBus` with the canonical
* `send` + `onOutbound` + `receive` surface, we return early and leave
* Injection.js's instance in place. Outbound `send()` calls still reach
* `window.webkit.messageHandlers.jarvisBus` because that handler lives on
* the same page world too.
* If we detect a pre-existing `window.jarvisBus` with the expected
* `send` + `onOutbound` + `receive` shape, we attach our options to it
* and return it verbatim rather than overwriting it. This routes every
* outbound `send()` through the JarvisBusWorld-captured message handler
* reference and keeps `onOutbound` hooked to the same `_handler` slot the
* Injection.js bus exposes. Without this branch the default-world bundle
* replaces `window.jarvisBus` with a bus whose `send()` cannot reach
* `webkit.messageHandlers.jarvisBus` and every outbound frame — including
* the first `uiReady` — is lost.
*
* `bus-harness.html` (Phase 2) loads its marker script in a page where
* bus-harness.html (Phase 2) loads its marker script in a page where
* Injection.js has installed the same bus; the harness never called `send()`
* so the regression wasn't observed until Phase 3. For that path this
* function still returns the pre-installed bus, which is exactly what the
@@ -94,11 +93,10 @@ export function installJarvisBus(options: InstallOptions = {}): () => void {
console.error("[bus] decode failed:", error, raw);
});
// H-02: defensive double-install guard. Injection.js runs first (at
// document-start in the same page world) and stamps a minimal bus with the
// same surface; if it's already on `window`, we MUST NOT replace it — doing
// so would orphan Injection.js's queued early messages and captured handshake
// state. See the class doc above for the full rationale.
// H-02: attach-to-existing path. Injection.js in JarvisBusWorld installs a
// minimal bus with the same surface; if it's already on window we MUST NOT
// replace it (that would orphan webkit.messageHandlers.jarvisBus, which is
// only visible inside JarvisBusWorld).
const existing: unknown =
typeof window !== "undefined"
? (window as unknown as { jarvisBus?: unknown }).jarvisBus
@@ -158,7 +156,7 @@ export function installJarvisBus(options: InstallOptions = {}): () => void {
/**
* Duck-type guard for a pre-existing `window.jarvisBus`. Matches the shape
* installed by `packages/Bus/Sources/Bus/Resources/Injection.js` at
* document-start in the page world. Intentionally narrow: if the shape
* document-start in JarvisBusWorld. Intentionally narrow: if the shape
* doesn't match (e.g. a left-over test stub), we fall through to the
* fresh-install branch and overwrite it.
*/
+12 -12
View File
@@ -74,13 +74,14 @@ describe("installJarvisBus", () => {
expect(b).toHaveBeenCalledOnce();
});
// H-02: both `Injection.js` (`WKUserScript` at document-start) and this
// bundle's `installJarvisBus()` run in the SAME page world since `fb41c5f`
// retired the prior isolated named content world. Injection.js fires first
// and stamps a minimal `window.jarvisBus`; if `installJarvisBus()` naively
// overwrote it, the early handshake state and queued messages captured
// against the original closure would be orphaned. Therefore the bundle's
// installer must attach to — not replace — a pre-existing bus.
// H-02: the HUD bundle (`main.tsx`) runs in the default JS world, but
// Swift registered `webkit.messageHandlers.jarvisBus` in JarvisBusWorld,
// so a default-world `send()` cannot reach the message handler. The
// Injection.js-installed bus in JarvisBusWorld CAN reach it (it captured
// the reference at document-start inside that world), and because
// `window.jarvisBus` is a cross-world-shared property the default world
// observes the Injection.js bus. Therefore `installJarvisBus()` must
// attach to — not replace — a pre-existing bus.
describe("H-02 attach-to-existing (pre-installed window.jarvisBus)", () => {
it("returns the pre-existing bus unchanged when one is already installed", () => {
const preExistingSend = vi.fn().mockResolvedValue(undefined);
@@ -95,10 +96,9 @@ describe("installJarvisBus", () => {
// @ts-expect-error — test shim: seed a pre-installed bus shape matching Injection.js
globalThis.window = {
jarvisBus: preExisting,
// Note: webkit is intentionally absent — Injection.js's pre-existing
// bus captures a reference to `window.webkit` at install time, and the
// shape guard must trust the pre-existing bus to route through that
// captured reference rather than re-resolving `webkit` here.
// Note: webkit is intentionally absent — in real JarvisBusWorld the
// pre-existing bus captured webkit at install time; the default world
// never sees webkit. The fix MUST NOT touch the pre-existing bus.
};
installJarvisBus();
@@ -110,7 +110,7 @@ describe("installJarvisBus", () => {
expect(window.jarvisBus.onOutbound).toBe(preExistingOnOutbound);
});
it("routes send() through the pre-existing bus's send (reaches Injection.js-captured handler)", async () => {
it("routes send() through the pre-existing bus's send (reaches JarvisBusWorld-captured handler)", async () => {
const preExistingSend = vi.fn().mockResolvedValue({ ok: true });
const preExisting = {
protocolVersion: "2.0.0",
+9 -9
View File
@@ -5,15 +5,15 @@ import { attachBus } from './bus/client'
import './theme/tokens.css'
// `attachBus()` runs in the page's default JS world (this module loads via
// `<script type="module" src="…">` in index.html). Swift's
// `WebviewBridge.installScriptHandlers` (since `fb41c5f`) registers the
// message handler on the same `WKContentWorld.page`, and Injection.js — also
// in the page world — has already stamped a minimal `window.jarvisBus` at
// document-start. `installJarvisBus()` here detects that pre-existing
// instance via a duck-type shape guard and returns early, leaving the
// Injection.js bus untouched so its captured handshake state and queued
// early messages are preserved. See `packages/bus/src/bridge.ts` for the
// full defensive-double-install rationale.
// `<script type="module" src="…">` in index.html). Swift installed the real
// `window.jarvisBus` inside `JarvisBusWorld` via Injection.js — WebKit
// content worlds isolate `webkit.messageHandlers`, so the default-world
// bundle can't reach them directly. `window.jarvisBus` IS visible though,
// because WebKit keeps `window` as one cross-world object (only *variables*
// are world-scoped). The H-02 fix in `@jarvis/bus` detects the pre-existing
// `window.jarvisBus` and attaches to it instead of replacing it, which
// routes every outbound `send()` back through the JarvisBusWorld-captured
// message handler reference. See `packages/bus/src/bridge.ts` for details.
attachBus()
const rootEl = document.getElementById('root')