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

Native Messaging and the Spawn Rule

~14 min · native-messaging, posix-spawn, process-groups, extensions

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Native messaging, as Chrome speaks it

An extension cannot open a socket to a desktop app on its own. Chrome gives it a narrower door. The desktop app registers a host manifest, a small JSON file in the browser's NativeMessagingHosts folder, naming a program, the type stdio, and the extension origins allowed to reach it, each written as chrome-extension://<id>/. When the extension calls connectNative with that host's name, the browser finds the manifest, checks the extension's origin against the list, and starts the program with the origin as its one argument. From then on they exchange JSON over the program's standard input and output, each message preceded by its length as a little-endian 32-bit integer.

Voyager speaks exactly that. Its registry looks in the folders Chrome and Chromium look in, per user and system-wide, so a desktop app that already talks to Chrome needs nothing new to talk to Voyager. The registry accepts only letters, digits, dots and underscores in a host name, so a name can never reach outside those folders. And this is where lesson 1's choice pays off: the manifest allows an extension by its Chrome id, and Voyager kept that id.

The first code block is the framing. Two details are worth stealing. Voyager refuses an outgoing message over 4 MB before it builds the frame, and an incoming one over 1 MB as soon as its 4-byte length arrives, before it waits for the rest or copies the message out. That is not before every allocation: the outgoing JSON is already serialized when its size is checked, and the incoming bytes the pipe has delivered already sit in the buffer. What the limits stop is building an oversized frame and waiting on a huge one, and an oversized incoming length ends the connection. And the unframing comment records a Foundation trap: after a removal, a Data value's indices no longer start at zero, so every index is computed from startIndex.

The refusal loop

By the afternoon of 2026-10-03, 1Password's helper had accepted Voyager's signature (lesson 5 of track 2 tells that part, and track 9 explains the signing). So the bridge started the helper from inside the app, the way Chrome does. It was refused anyway. From the minute the extension was installed, one helper was refused every minute, each dead in about 13 ms, each leaving its own log file behind.

The refusal said the host was "not part of the main browser's tree". Disassembling the helper's browser-verification code and capturing the parent of a dying helper live showed what "tree" meant: the process group. The helper compares its own getpgrp() with the browser process's group and refuses a mismatch. Foundation's Process puts every child in a group of its own; measured on that Mac, a Process child's group id was its own pid, while a posix_spawn child inherited its parent's. Chrome's and Comet's live helpers on the same Mac carried their browser's group id.

The first rule did not tell the whole story. An experiment re-launched Voyager's own binary as a relay child that joined the browser's group and started the host beneath it. The refusal moved to a different line: "required parent browser path was not available". That is a second rule. The helper's direct parent must be a browser Launch Services can name, so no intermediate can hold the pipe. The relay had done its job as an instrument; it answered one question, and the shipping path never uses it.

The fix is Chrome's own shape, exactly: the app process starts the host with plain posix_spawn, and asks for no process group of its own. The second code block is that call, with the measurement in its comment. With it the helper logged that it was verifying Voyager, then that it was waiting for partner authorization, and the every-minute refusal loop was gone.

What the port writes down

A native port to a password manager carries that password manager's protocol. So the port logs who sent a message and how many bytes, never what it said. The host's comment is blunt: "Protocol bodies can contain secrets, including the first reply." The host program's standard error goes to /dev/null as well. The direction and size were enough to see that the helper answered, and nothing more was ever needed in a log.

The other direction: chrome-extension:// addresses

Native messaging carries the extension to the desktop app. Pairing also needs the reverse: 1Password's Add browser opens the extension's own app page in the chosen browser, by a chrome-extension:// address. WebKit serves an extension's pages from its own webkit-extension scheme under a base URL it generates per context, so Voyager has to translate. Three walls stood in the way, each measured and removed the same day: the address was dropped when the app opened it, the engine's scheme list cancelled webkit-extension, and WebKit serves an extension page only to a web view built with that context's own webViewConfiguration; a plain configuration is refused with NSURLError -1008.

Now pageURL(for:) finds the installed context by the address's host, which is Chrome's id again, and builds the page URL from the context's base with the path, the query and the fragment carried over. makePage(forExtensionPageAt:) builds the page with the context's configuration and puts the compatibility layer in front of its scripts. The next lesson shows why one of those three parts, the fragment, turned out to matter.

Code

cwkVoyager, Sources/VoyagerCore/NativeMessaging.swift: Chrome's framing, a little-endian length and the JSON bytes (verbatim, lines 76-102)·swift
/// Chrome's framing: a little-endian UInt32 length, then the JSON bytes.
public enum NativeMessageFraming {
    public static let maximumIncoming = 1024 * 1024
    public static let maximumOutgoing = 4 * 1024 * 1024

    public static func frame(_ json: Data) throws -> Data {
        guard json.count <= maximumOutgoing else { throw NativeMessagingError.tooLarge(json.count) }
        var length = UInt32(json.count).littleEndian
        var frame = Data(bytes: &length, count: 4)
        frame.append(json)
        return frame
    }

    /// Takes every whole message off the front of `buffer`. Data's indices
    /// are not zero-based after a removal, so every index is relative.
    public static func unframe(_ buffer: inout Data) throws -> [Data] {
        var messages: [Data] = []
        while buffer.count >= 4 {
            let start = buffer.startIndex
            let length = buffer[start..<(start + 4)].withUnsafeBytes { Int(UInt32(littleEndian: $0.loadUnaligned(as: UInt32.self))) }
            guard length <= maximumIncoming else { throw NativeMessagingError.tooLarge(length) }
            guard buffer.count >= 4 + length else { break }
            messages.append(Data(buffer[(start + 4)..<(start + 4 + length)]))
            buffer = Data(buffer[(start + 4 + length)...])
        }
        return messages
    }
cwkVoyager, Sources/VoyagerCore/NativeMessaging.swift: the host is spawned in the browser's process group, never in one of its own (verbatim, lines 152-175)·swift
    public func start() throws {
        let launch = launch
        let argv: [UnsafeMutablePointer<CChar>?] = [strdup(launch.program)] + launch.arguments.map { strdup($0) } + [nil]
        defer { for case let argument? in argv { free(argument) } }
        var actions: posix_spawn_file_actions_t? = nil
        posix_spawn_file_actions_init(&actions)
        posix_spawn_file_actions_adddup2(&actions, input.fileHandleForReading.fileDescriptor, 0)
        posix_spawn_file_actions_adddup2(&actions, output.fileHandleForWriting.fileDescriptor, 1)
        posix_spawn_file_actions_adddup2(&actions, FileHandle.nullDevice.fileDescriptor, 2)
        var attributes: posix_spawnattr_t? = nil
        posix_spawnattr_init(&attributes)
        // No POSIX_SPAWN_SETPGROUP on purpose: the host must stay in the
        // browser's process group. 1Password's helper refuses a host that
        // left it ("not part of the main browser's tree", apple.rs:133), and
        // Foundation's Process puts every child in a group of its own —
        // measured 2026-10-03, the reason every host it started was refused.
        let spawned = argv.withUnsafeBufferPointer { buffer in
            posix_spawn(&pid, launch.program, &actions, &attributes, buffer.baseAddress, nil)
        }
        posix_spawn_file_actions_destroy(&actions)
        posix_spawnattr_destroy(&attributes)
        guard spawned == 0, pid > 0 else {
            throw NativeMessagingError.couldNotStart("posix_spawn answered \(spawned)")
        }

External links

Exercise

Write a tiny native-messaging host that reads a 4-byte little-endian length and a JSON message from standard input and writes back the same framing with an echo. Then start it from a small macOS program twice, once with Foundation's Process and once with posix_spawn, and have the host report its own process group id and its parent's pid in the reply. Compare both against the parent program's process group.
Hint
In a Python host, os.getpgrp() and os.getppid() give the two numbers; struct.unpack('<I', raw) reads the length. From a shell, ps -o pid,ppid,pgid,comm shows the same facts for running processes. Without POSIX_SPAWN_SETPGROUP in the spawn attributes, a posix_spawn child stays in its parent's group.

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.