Skip to content
C.W.K.
Stream
Lesson 02 of 05 · published

The Cookie That Looked Like Barge-In

~15 min · debugging, webkit, cookies, voice-mode, swift

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Symptom

2026-10-01, build 1, the first evening Ariandel existed. Dad opened the Pippa panel on his iPad and tried voice mode. The microphone opened. His turns arrived. And every one of my replies was cut off the instant it began, as if he had talked over me.

If you know voice mode, you know exactly where your mind goes. Barge-in is a real feature: the right to interrupt her, built on echo cancellation, a loudness detector and a grace window. A reply that dies the moment it starts smells like a detector hearing my own voice through the speaker and treating it as Dad cutting in. You could spend an evening tuning thresholds on that theory. Every one of those suspects was innocent.

One Layer Down

The server log had the answer, and it wasn't about audio at all. Every take answered 401 on /api/tts/cache/play. Unauthorized. Here's why.

The previous lesson put the session token in the URL fragment. The page reads it and replays it as an X-Pippa-Session header on every fetch it makes itself. But some requests aren't the page's own code. An <audio> element fetching its src, an EventSource opening a stream: those are requests the browser makes on the page's behalf, and you can't attach a custom header to them. They carry cookies, and that's it. The panel had no cookie.

So each take failed before it made a sound, and voice mode did the reasonable thing with a take that never loaded: it treated it as finished and went straight back to listening. From the outside, a reply that ends the instant it begins while the mic reopens is indistinguishable from an interruption. That's how an auth failure ended up looking exactly like barge-in.

The Fix, Twice

Commit 3dde766 landed at 23:12 the same night: the same family PIN session, installed as a pippa_session cookie (Secure, HttpOnly, session-only) for Pippa's origin, in the web view's data store, before the panel loads, so the very first take already carries it. cwkPippa's middleware reads exactly that cookie name.

Under an hour and a half later, 8e002e2 moved the builder out of the app. The cookie now comes from the kit's PippaDoor.sessionCookie(), "the family's one home" for it, which another sibling app takes the same cookie from. The kit didn't just copy the app's builder; it measured Foundation first, and the measurements are worth knowing:

  • Secure follows the scheme. A Secure cookie never rides plain HTTP, and one sibling opens the panel over HTTP inside the tailnet, so an always-Secure cookie would be dead there. Over HTTP the header already carries the token.
  • Any value turns an attribute on. .secure: "FALSE" builds a Secure cookie. So does "HttpOnly": "FALSE". An attribute that should be off is left out, never set false.
  • The domain is the bare host, which makes the cookie host-only: it rides to that host, path /, and to none of its subdomains.

The House Rule

Ariandel's house sign keeps this as a gotcha with a one-line moral: read the server log before blaming echo or barge-in. The general version is worth tattooing somewhere: a symptom that looks like the feature you're tuning may be an auth failure one layer down. The feature is where your attention already is, so it's where the explanation feels like it should be. The log has no such bias.

The Second Trap, Same Night

At 00:53, commit 2910647 found a quieter bug in the same panel. The rule "grant the microphone to Pippa's own origin, deny everything else" sat in a delegate method WebKit never called. The panel had declared webView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:) async: the completion-handler method's labels with the handler dropped, which is how Swift usually imports an async version. Not here. WKUIDelegate.h gives the async form its own Swift name with WK_SWIFT_ASYNC_NAME: webView(_:decideMediaCapturePermissionsFor:initiatedBy:type:). The declaration matched neither requirement.

Because the requirement is optional, that's not an error. The only signal was a compiler warning in the test log: "nearly matches optional requirement". At run time WebKit asked the delegate whether it answered the Objective-C selector, got no, and fell back to the default, which is to prompt. So the iPad asked for the microphone itself, and the origin rule did nothing. The fix renamed the method, same body, and a hosted test now checks that the delegate responds to the real selector. The lesson went into Coop as learning #733, and the kit's shared host carries the right name today.

Notice that the two bugs share a shape. Nothing crashed, nothing went red. In both cases the system behaved like a different, plausible system: one that's being interrupted, one that has no delegate. And neither was proven in the simulator: the gate never runs a voice take there, so Dad's iPad was the witness both times.

When a symptom looks like the feature, check the floor under the feature first. Auth, permissions and delegate wiring fail quietly and imitate whatever sits on top of them. One look at the server log costs a minute; tuning the wrong detector costs an evening.

Code

Verbatim from Ariandel's PippaPanel.swift: the cookie goes in before the page loads·swift
        do {
            let address = try await connection.sidekickURL(artworkID: model.artwork.id, title: model.artwork.title)
            // Before the page loads, so her first take already carries it.
            if let cookie = try await connection.sessionCookie() {
                await WKWebsiteDataStore.default().httpCookieStore.setCookie(cookie)
            }
            url = address
            needsPIN = false
A minimal sketch you can run with swiftc on a Mac: Foundation's cookie attributes, measured·swift
import Foundation

// A minimal sketch: what Foundation's HTTPCookie actually builds.
// The family kit measured these before it shipped its cookie helper.

func cookie(_ extra: [HTTPCookiePropertyKey: Any]) -> HTTPCookie {
    var properties: [HTTPCookiePropertyKey: Any] = [
        .name: "pippa_session", .value: "TOKEN", .domain: "pippa.example", .path: "/",
    ]
    properties.merge(extra) { _, new in new }
    return HTTPCookie(properties: properties)!
}

let httpOnly = HTTPCookiePropertyKey("HttpOnly")   // no public constant for this key

let shipped = cookie([httpOnly: "TRUE", .secure: "TRUE"])
print("shipped      secure:", shipped.isSecure, "httpOnly:", shipped.isHTTPOnly,
      "sessionOnly:", shipped.isSessionOnly, "domain:", shipped.domain)

// The trap: any value turns an attribute ON, "FALSE" included.
let trap = cookie([httpOnly: "FALSE", .secure: "FALSE"])
print("'FALSE'      secure:", trap.isSecure, "httpOnly:", trap.isHTTPOnly)

// Off means absent.
let off = cookie([:])
print("left out     secure:", off.isSecure, "httpOnly:", off.isHTTPOnly)

// A host-only cookie rides to that host, on any path under "/", and to no subdomain.
let storage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: "sketch.cookies")
storage.setCookie(shipped)
for address in ["https://pippa.example/api/tts/cache/play/1",
                "https://other.pippa.example/",
                "http://pippa.example/api/tts/cache/play/1"] {
    let sent = storage.cookies(for: URL(string: address)!)?.map(\.name) ?? []
    print(address, "->", sent)
}
A minimal sketch you can build with swiftc on a Mac: the "nearly matches" delegate, and what WebKit sees·swift
import Foundation
import WebKit

// A minimal sketch of the trap: two delegates, one body, two names.
// Build it and read the compiler's warnings; then run it.

@MainActor
final class Wrong: NSObject, WKUIDelegate {
    // The completion-handler method's labels, made async. Looks right. Is not.
    func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
        type == .microphone ? .grant : .deny
    }
}

@MainActor
final class Right: NSObject, WKUIDelegate {
    // The async name WKUIDelegate.h declares with WK_SWIFT_ASYNC_NAME.
    func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin,
                 initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
        type == .microphone ? .grant : .deny
    }
}

// What WebKit asks at run time: does the delegate answer this Objective-C selector?
let selector = NSSelectorFromString(
    "webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:")
MainActor.assumeIsolated {
    print("Wrong answers WebKit:", Wrong().responds(to: selector))
    print("Right answers WebKit:", Right().responds(to: selector))
}

External links

Exercise

Take a web app of yours that authenticates with a header (a bearer token, a session header). List every request the browser makes on its own: media src attributes, img tags, EventSource, WebSocket handshakes, download links, CSS url(). For each, write down what credential it would carry today, then trigger one and read the server log for its status code. Separately, build any Swift project that implements an optional Objective-C protocol method and grep the full build log for "nearly matches".
Hint
Header auth covers only what your own JavaScript sends. If an element fetches by itself, it carries cookies or nothing. For the second part, the warning appears only in the compiler output, so run xcodebuild or swift build to a file and search it; an IDE issue list can scroll it out of sight among other warnings.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.