Engineering4 min read

"zsh: killed" on macOS After Re-downloading a Binary: the Code Signature Cache, and the One-Line Fix

A provider re-ran our Mac install command and every launch of the agent died instantly with "zsh: killed". The binary was fine, the same file ran on other Macs, and there was no crash log. The cause is how macOS caches code signatures per file: curl -o over an existing executable leaves one the kernel refuses to start. Here is how to recognise it, why it happens, and the fix for your install scripts.

The Paralon capybara at a Mac, holding a small file with a broken wax seal while a sealed copy glows on the desk

A GPU provider sent us this, from a Mac with an M4 Max:

$ TOKEN=node_… ~/.paralon/paralon-agent install
zsh: killed     TOKEN=node_… ~/.paralon/paralon-agent install
$ ~/.paralon/paralon-agent logs
zsh: killed     ~/.paralon/paralon-agent logs

Every invocation, killed before it printed a line. Even logs, which does nothing but run tail -f. No crash report in Console, nothing in our logs, because the process never lived long enough to open a socket.

We checked the obvious first. The binary in the release was intact: 6,678,354 bytes, the size curl had reported on their machine, arm64 Mach-O, ad-hoc code signature present, SHA-256 matching the published one. Three other Macs were running that exact file. So the file was fine and the machine could run it. What could make the kernel refuse?

What "zsh: killed" means

killed is the shell reporting that the process received SIGKILL. When it happens at launch, before main() runs, it is not your program crashing; it is the kernel declining to execute the file. On Apple Silicon the usual reason is code signing: every executable must carry a valid signature, even an ad-hoc one, and a binary whose signature does not match its contents is killed on exec.

The important detail is how macOS checks that. The signature is validated per file, and the result is cached and tied to the file's identity on disk, its vnode. Modify the file's bytes without creating a new file, and the cached verdict and the contents disagree. The kernel does not re-verify; it kills.

How the provider got there

They had installed the agent before. Our one-line install did this:

curl -L https://…/paralon-agent-darwin-arm64 -o ~/.paralon/paralon-agent
chmod +x ~/.paralon/paralon-agent

curl -o onto a path that already exists opens the existing file and overwrites its contents in place. Same inode, new bytes. The downloaded binary was perfect; the file it was written into was no longer the file the kernel had validated. Every launch after that: killed.

Our agent's own self-update never hit this, which is why we had not seen it. It downloads to a temporary file and renames it over the old one. A rename swaps in a new inode with its own fresh signature check. Same bytes, different outcome.

Confirm it in ten seconds

codesign -vv ~/.paralon/paralon-agent

A healthy binary prints valid on disk and satisfies its Designated Requirement. One in this state prints an error about the signature or the resources being modified. And for completeness, that the file is what you think it is:

file ~/.paralon/paralon-agent          # Mach-O 64-bit executable arm64
shasum -a 256 ~/.paralon/paralon-agent # compare with the published hash

If the hash matches the release and codesign still complains, it is this problem, not a corrupt download.

The fix

For the machine in front of you:

rm -f ~/.paralon/paralon-agent
curl -L https://…/paralon-agent-darwin-arm64 -o ~/.paralon/paralon-agent
chmod +x ~/.paralon/paralon-agent

Delete, then download. The new file is a new inode and gets a clean verification.

For your install script, so nobody hits it again, either delete before downloading, or download to a temporary name and mv it into place:

curl -L "$URL" -o "$DEST.tmp" && chmod +x "$DEST.tmp" && mv "$DEST.tmp" "$DEST"

Ours now does the first, and also stops a running agent before touching the file:

mkdir -p ~/.paralon && (~/.paralon/paralon-agent stop 2>/dev/null; rm -f ~/.paralon/paralon-agent) \
  && curl -L … -o ~/.paralon/paralon-agent && chmod +x ~/.paralon/paralon-agent

The change is in the command every provider copies from their dashboard, so a second install of the Mac agent is now the same as a first one.

When it is not this

Two lookalikes, quickly ruled out:

  • bad CPU type in executable: an arm64 binary on an Intel Mac. A different message, and uname -m says x86_64.
  • Gatekeeper's dialog ("cannot be opened because the developer cannot be verified"): that is for apps launched from Finder, or files carrying the quarantine attribute, which a curl download does not set. It shows a window, not killed.

And if codesign -vv is clean, the hash matches, and it is still killed, look at endpoint security software on the machine; some of it kills unsigned or ad-hoc-signed binaries by policy, and the symptom is identical.

The general lesson

Never overwrite a running or previously verified executable in place on macOS. curl -o, cp onto the target, cat > file, dd: all keep the inode. rm then write, or write elsewhere then mv: both create a new one. It costs nothing and it removes an entire class of "works on my Mac" support tickets.

On the network

A GPU with JupyterLab on it, in about a minute

Billed for the minutes it runs, on a machine we benchmarked before listing it. Or call open models through an OpenAI-compatible API.

Keep reading

Related articles

The Paralon capybara holding one glowing cable that splits toward three machines: a Linux tower with graphics cards, a Windows laptop, and a Mac
Engineering

10 min read

One Model Name, Three Engines: vLLM on Linux, vLLM on WSL and llama.cpp on Apple Silicon Behind One Endpoint

The same model ID is now served by vLLM on Linux GPUs, vLLM on Windows/WSL, and llama.cpp on Apple Silicon Macs, and the caller cannot tell which one answered. Here is what had to be made equal for that to be true, the tokens-per-second we measured on an M1 Ultra and an M4 Max next to the GPUs, the 27B experiment that failed first and the rules it left behind, and a self-update bug that only exists when your inference server is a child process.

Read
The Paralon capybara as a gatekeeper lifting a barrier on a lane marked for robots while keeping the main gate closed
Engineering

4 min read

Cloudflare Error 1010: Your API Is Returning 403 to Python Clients and Nothing in Your Logs Shows It

Our OpenAI-compatible API answered curl, Node, python-requests and every SDK with 200, and Python's urllib with a 403 that never reached our servers. The cause was Cloudflare's Browser Integrity Check, which is right for web pages and wrong for an API. How to recognise error code 1010, how to prove it is the edge and not your app, and the Configuration Rule that turns the check off for /v1/ only.

Read
The Paralon capybara holding a glowing thread that has been cut, tying it back onto a second reel
Engineering

6 min read

"Stream ended without finish_reason": What an OpenAI-Compatible API Should Do When the Worker Dies Mid-Stream

A customer's coding agent showed us a screenshot: Error: Stream ended without finish_reason. On our side the request row said completed. Both were wrong in their own way. Here is what happens to a streamed completion when the GPU worker's connection drops, why the common gateway behaviour turns it into a silent truncation, and the three rules we now follow: retry before the first token, an explicit error event after it, and never count a cut stream as a success.

Read