Skip to main content
Blog
Deep diveEncryption / Transcription / BYOK providers / Cloud terminals / Measurement

The Encryption That Threw Away the Compression

Seven releases in seven days, v0.6.22 through v0.6.28: the desktop tunnel gained end-to-end encryption on all but its streaming paths, Foxl Notes learned to transcribe on the Mac itself, and nineteen bring-your-own-key vendors got real controls. Encrypting the tunnel turned a 224 KB download into 1536 KB, because ciphertext does not compress. Amazon Transcribe accepted a socket for a whole meeting and refused to transcribe a word of it. A cached token was counted twice and then priced, so $0.38 of usage read as $4.21. Twelve technical gotchas, each with the number that made it visible.

Foxl Team18 min read

Four gotchas from Foxl v0.6.22-v0.6.28 as the numbers that exposed them: a 1536 KB download that had been 224 KB before encryption, a /API/ path that skipped the token check, a transcription socket that opened while the transcript stayed empty, and $4.21 billed for $0.38 of usage.
On this page
Reviewed against the shipped implementation and retained tests on September 2, 2026.

Between August 26 and September 1 Foxl shipped seven releases, v0.6.22 through v0.6.28. Three of them were large: the desktop tunnel gained end-to-end encryption, Foxl Notes learned to transcribe on your own Mac with no audio leaving it, and nineteen bring-your-own-key vendors got real controls instead of a lone API key field.

One qualification on the first of those, because it matters for what follows. Terminal traffic, request bodies and request paths are encrypted between your two ends. Three payload types are still plaintext to the relay on purpose: a chat reply while it is streaming, a file preview's raw bytes, and a notification's title and body. The first two travel in the body of an HTTP response the relay holds open rather than over the socket, so nothing on the receiving side would decrypt them, and the third is the text the relay itself composes into the push your phone shows. So this is encryption on the tunnel with a documented remainder, not a finished claim.

This is the other half of that work. Twelve things that were already true while every gate was green, each one found by measuring the far end rather than by reading our own code harder. They are grouped by the shape of the mistake, because the shape is the part that transfers.

1. Encrypting a payload destroys the compression you were getting free

The tunnel gained that encryption in v0.6.27, and opening a long conversation on a phone got several seconds slower. Not a little slower. A response that had been arriving as 224 KB started arriving as 1536 KB.

Cloudflare compresses responses in transit, and ciphertext is indistinguishable from random, so there is nothing in it to compress. One real conversation, measured end to end: 1521 KB of plaintext left the desktop and reached the phone as 221 KB. Encrypted first, the same conversation became a 2028 KB envelope that compressed to 1536 KB, which is 6.9 times worse than before the feature. The encryption was working perfectly. It had simply taken a large saving off the table, and nothing on our side was measuring bytes on the wire.

The fix is the order of two operations: compress, then encrypt. Compressing first gives 295 KB of gzip that the network then leaves nearly alone, arriving as 224 KB. Loading that conversation goes from 1.93s to 0.59s on average LTE and from 4.55s to 0.97s on a weak connection.

Two details are worth stating because they are the ones that turn this into a defect rather than a win. Gzip level 6 is the choice, not level 9: on that payload 6 costs 13.7 ms for 221 KB and 9 costs 41.2 ms for 215 KB, so the last 6 KB triples the CPU. And the receiver must bound the inflate. Decompression is an amplifier, and a 260,934-byte input expanded to 256 MB in 109 ms when we pointed one at it deliberately. Both halves cap the inflated size at 64 MiB and only inflate after the authentication tag has verified, so a forged frame is never fed to a decompressor at all.

Two pipelines for the same 1521 KB conversation. Encrypting first produces a 2028 KB envelope that the network gzip cannot compress, arriving as 1536 KB. Compressing with gzip level 6 first produces 295 KB that encrypts and arrives as 224 KB, cutting the load from 1.93 seconds to 0.59 seconds on LTE.
Figure 1. The network behaves identically in both lanes. Only the order of the two operations changed.

2. A new field on an envelope has to be forwarded by everything in the middle

Compression is negotiated per connection and marked on each envelope by a single field. The desktop sets it, the client reads it. The relay sits between them and copies envelopes across an explicit list of fields, and the request direction copied three fields where the response direction copied four.

So a compressed request left the phone, arrived with its marker removed, and was decrypted into gzip bytes that were not valid anything. Nothing reported it. The desktop could not answer, the relay waited out its ten-second request timeout, and the tunnel concluded the desktop had gone away. Saving a long note simply did not happen. The trigger was a 4070-byte body, which is above the 1024-byte floor where compression switches on, so short requests were fine and that is most of what a person does.

The relay now derives all three forwards from one constant naming the transport fields, so the enumeration exists in a single place. The general version of this: if a wire format grows a field, the code that relays that format has as many copies of the field list as it has directions, and the one you did not edit fails silently by producing a well-formed message with a missing header.

3. Turning bytes into a string has an argument-count ceiling, and it is per-engine

The browser half base64-encoded a payload by spreading a byte array into a function call. Every byte becomes an argument, so a large enough payload overflows the call stack. The failure was swallowed by a catch that ignored it, and the encrypted send then quietly fell back, so a large request from the phone or the browser to the desktop never arrived and never complained.

Bisected, the ceiling is 123,949 bytes in V8 and 638,766 bytes in JavaScriptCore. That gap is the whole reason this shipped: Chrome on a desktop fails at about 121 KB, Safari and every iOS browser fail at about 624 KB, so the same code has two different sizes at which it stops working and neither of them is a round number anyone would think to test. Chunking the conversion in 32 KB blocks removes the limit entirely, and 1300 KB now encodes in 4.1 ms on the engine that used to be the more forgiving one.

4. A permission check has to normalise the path the same way the router does

The desktop app runs a local server so the web and phone apps can reach it through the relay, and a secret token is the only thing stopping any other program on the same machine from using it. The guard skipped requests whose path did not begin with a lowercase prefix. The router that dispatches those same requests is case-insensitive.

So a request to a path spelled in capitals was routed normally and was never asked for the token. That reached the terminal, the tool runner, the custom tool executor, an open-anything-on-this-machine endpoint, and the list of provider API keys. The guard now lowercases before it decides, exempts only the health endpoint, and compares the token by hashing both sides and using a constant-time comparison. The single-page fallback was fixed in the same pass for the same reason: an unmatched path in capitals used to be served the app shell, which carries an injected token, so the fallback was a second way to ask.

Any two components that both interpret a path must agree on the normalisation, and a guard that is stricter than its router is a bypass, not a safety margin.

Sign-in on the phone opens the system browser and comes back through a custom URL scheme. Any web page you visit can open that scheme. So a page could hand the app a session that the app had not asked for, replace yours with it, and collect the notes, chats and files you made afterwards.

The app now mints a random value before it sends you out, requires it back on the return, and drops the callback if it does not match. The interesting constraint is where that value lives: it has to survive the operating system killing the app while the browser is in front, which rules out anything scoped to the page session. It is stored durably, at most five outstanding, single use, with a thirty-minute expiry chosen to sit outside the relay's own ten-minute window so the app never rejects a state the server would still have accepted. The desktop apps were never exposed, though for a different reason than the one we assumed: they gate on a short pending-login window rather than on a nonce.

6. A packaging allowlist is transitive, and the check for it was not

The first build of v0.6.27 did not open. It stopped at launch with Cannot find module './tunnel-sessions.js' and an error dialog instead of a window, on all three desktop platforms, because the packaged app ships an explicit file list and one file the tunnel requires was not on it.

There was a gate for exactly this. It read the two entry files and checked they were packaged, which they were. A file required by a file that is on the list is just as load-bearing, and nothing walked the second hop. The gate now breadth-first walks the module-scope requires from the entry point and asserts every local file it reaches is in the allowlist, with a positive control that fails if the walk stops before reaching the file that caused this. An app that cannot start also cannot update itself, which is what makes this class worth a gate rather than a fix.

7. A socket that opens proves nothing about a credential

Amazon Transcribe accepts a streaming WebSocket on the shape of the signature. It checks the credential when audio starts flowing, and it reports a refusal as a frame inside the stream, then closes with a normal code and the text "Your request completed with an exception." Every layer above sees a successful connection followed by a clean close.

This produced two separate defects. On the phone, the one-hour scoped credential the desktop mints allowed the wrong one of Transcribe's two streaming actions: the HTTP one, not the WebSocket one. The handshake was accepted, audio streamed for an entire meeting, and Amazon transcribed none of it. On a desktop with an expired AWS session, the same shape produced a live microphone, a Recording label, and silence, with Amazon's own explanation written to a developer console that a phone and a packaged app do not have.

Three things changed. The exception frame is parsed and its message shown as it arrives, so a recording stands down instead of running to the end and saving an empty transcript. The desktop refuses to sign a socket at all when its credentials expire within the next minute, because failing at the start is what lets a phone fall back to another route and keep recording. And the probe that covers this mints the old broken policy on purpose as a control, and fails if that policy still opens a socket and returns nothing, since a handshake succeeding is precisely the signal that cannot be trusted here.

8. "Input tokens" means two different things

Bedrock's Claude models report the cached part of a prompt beside the input count. OpenAI-shaped usage, which is what GPT-5.x, Grok and Gemini return, reports an input count that already contains the cached part. Foxl added the two together for both families, so on the second one it counted every cached token twice and then charged the duplicate at the full input price on top of the cache price.

One real call: 764,937 input tokens of which 764,750 were cache reads. The heads-up display showed 1,529,687 tokens against a 1,000,000-token window, a context bar reading more than 100% for a request that had fitted comfortably, and $4.21 where the truth was $0.38. Every number was internally consistent and 10.9 times too high.

There is now one normaliser at the point of ingest, producing one shape for every provider, and it subtracts only for providers on a cache-inclusive list and only when the arithmetic allows it. It keys on the model as well as the provider, because 102 rows in one database carried a Bedrock provider with a GPT model name, and the wire format follows the model. History is repaired once on first launch behind a marker: on the machine this was found on, that pass rewrote 1,839 rows, removed 421.7 million phantom tokens, and moved the lifetime figure from $9,381.85 to $7,274.37.

9. A model id is a string that belongs to exactly one vendor

OpenRouter reserves the whole requested reply length against your balance before the model sees the prompt, and refuses outright when the balance cannot cover it:

402 This request requires more credits, or fewer max_tokens.
You requested up to 128000 tokens, but can only afford 15844.

Foxl asked for 128,000 output tokens, and the number was not that model's. The chat path looked the model up in Foxl's own catalog, which holds no OpenRouter rows, and then fell back to a substring match on display names. z-ai/glm-5.2 contains glm-5, so it matched a Bedrock entry and inherited its ceiling. openai/gpt-5.6 matched nothing and took a hardcoded 64,000. Neither figure had anything to do with the vendor answering the request. The same logic had a second victim: anthropic/claude-opus-5-fast contains claude, so it received a thinking-budget bump on a route that had not asked for thinking.

The vendor publishes the real ceiling, and for that model it is 262,144. A turn now asks for the smallest of the vendor's figure, your own setting, and a modest default, and the substring match is switched off entirely whenever one of these vendors is active. A fuzzy match across two namespaces does not fail loudly; it returns a plausible number from the wrong catalog.

The same release put four settings and a spend readout on the provider row. One of them taught us something about vendor strictness worth passing on: asking for guaranteed parameter support, refusing operators that train on your data, and disallowing fallbacks all at once left one model with no operator satisfying all three, and the vendor answered 404 No endpoints found that can handle the requested parameters. The settings screen says which of those can leave a model with nowhere to run.

10. An identifier can be right for the transport and wrong for the identity

Nineteen vendors reach their models through one OpenAI-compatible client: OpenRouter, DeepSeek, Groq, Z.ai, Together, Mistral, xAI, a local server, and the rest. Internally, selecting any of them stored the provider as openai with the vendor named in an environment variable. That is a reasonable way to reuse a client, and it meant every question of the form "which provider is this?" got an answer that was true of the wire protocol and false of the vendor. Four defects came out of that one fact.

  • Chat answered a bare 500. The stream route re-applied the provider from the message body and looked up openrouter in a registry that had never contained it. Measured four times in two minutes on the shipped app, one second after the picker had selected it successfully. All nineteen failed identically.
  • A second, unusable OpenAI section appeared in the model picker. The OpenAI provider's "am I configured?" test answered for the current selection, whose key belonged to the compat vendor. Choosing a model from that section sent an OpenAI model name to OpenRouter.
  • Notes AI refused with "OpenAI API key not configured" in 17 ms while the same model answered fine in chat, because it called a module that only ever read the OpenAI key. With an OpenAI key also saved, the refusal became a real request to OpenAI carrying another vendor's model id under your own key, which is the version that costs money.
  • The catalog came back empty. The response cap was 512 KB and OpenRouter's catalog measured 640,357 bytes for 387 models, so the read was cancelled and the error path produced a provider that appeared to serve nothing. It reads up to 4 MiB now.

One resolver classifies a provider id once and is shared by the picker and the chat route, and switching back to a direct provider clears the compat endpoint and key, which was a fifth bug in the same family: the next request went to the vendor you had just left, carrying that vendor's key.

A cloud coding terminal running Kiro drew its welcome screen and then sat on Initializing forever. Measured: 255 bytes of output in 22 seconds, and a composer that never arrived. Its newer engine claims a session by hard-linking a ticket file, and the network filesystem the cloud workspace lives on is the one kind of disk that refuses that operation. It returns an unmapped errno and the message "Unknown system error", while rename and exclusive-create both work, so nothing about the disk looks broken.

The fix moves the agent's session directory to local disk and leaves the checkout where it was. The trap inside the fix is worth the sentence: the vendor-specific home variable was not the lever, because the child process that takes the lock resolves the user's home directory itself. Only setting the actual home variable moved the lock. Two adjacent cases had the same texture: a task dispatched to Kiro was never affected, because that path runs the older engine, and an earlier sqlite lock failure on the same disk needed a different variable again.

Alongside it, three first-run prompts now get answered before the agent launches rather than drawn to a screen nobody can type at: a directory-trust question, a config-upgrade offer answered "not now", and a trust-all-tools confirmation. With both Kiro answers pre-seeded the composer arrives in 3.4 seconds instead of never.

12. A verdict anchored on a start time can never change its mind

Ask a cloud machine for a terminal's current screen and it answers "I have no session for this" in two entirely different situations: a fresh machine that has not finished booting, and a machine recycled after fifteen idle minutes. The two were indistinguishable, so the pane guessed, and it guessed with a grace period measured from when the task began.

Once that grace expired, the verdict was permanent. One momentary failure twenty minutes in read as gone for as long as the terminal stayed open. Measured in production: a task answered the gone status for 900 consecutive seconds while running normally, with a painted 5301-byte frame sitting in it.

The screen request is now not the only witness. The server also asks whether the machine's own output stream is connected, which stays true for a terminal that is simply idle and waiting, so a single bad answer recovers by itself. And v0.6.28 fixed the mirror image in the keystroke queue: typing during startup is held on the server and delivered in order, but a queue that held anything answered "still starting" to everything behind it, so one early keystroke could pin a pane to that message for the rest of its life and it could never offer Reconnect. The hold is now bounded by the same startup window it belongs to, and only a genuinely missing session ends it.

Any state machine whose transition depends on a deadline needs a second, positive signal to leave the state. A timer alone can enter a failure verdict and has nothing that can contradict it.

What the twelve have in common

Eleven of these were invisible to a green build, and the twelfth was an app that could not open. None of them are the kind of mistake that a type checker, a lint rule or a unit test over our own fixtures could have reached, because in every case our code did exactly what it said. What was wrong lived on the other side of a boundary: a compressor that had nothing left to compress, a router that lowercases, an engine with a different argument limit, a filesystem without hard links, a vendor that authorizes late, a catalog belonging to somebody else.

Which is also why the one measurement that came back boring is worth ending on. On-device transcription shipped pointing at the speech model's unquantized build, and the cost turned out to be almost entirely in generating text rather than in listening to audio: on an M5, a 19 second stretch of speech took 4093 ms, and the 8-bit build took 923 ms. The obvious worry is accuracy, so we checked it in English and Korean, on clean audio and with noise added, and the two builds produced character-for-character identical output. The download dropped from about 2 GB to about 1.3 GB and the worst live-caption delay went from 4.34 s to 0.42 s. Sometimes measuring the far end tells you the cheap option was free all along, and you only know which kind of answer you have after you go and look.

References and further reading

  1. Foxl changelogRelease
  2. Download FoxlReference