Skip to content

fix(update): never self-update a frozen binary; point at its own channel - #552

Merged
padak merged 4 commits into
mainfrom
fix/frozen-binary-self-update
Aug 2, 2026
Merged

fix(update): never self-update a frozen binary; point at its own channel#552
padak merged 4 commits into
mainfrom
fix/frozen-binary-self-update

Conversation

@padak

@padak padak commented Aug 2, 2026

Copy link
Copy Markdown
Member

kbagent ships both as a Python distribution and as a self-contained
PyInstaller binary delivered by Chocolatey, WinGet, Homebrew, apt, dnf or a
signed zip. Nothing in the code detected the difference: grep -rn "sys.frozen\|_MEIPASS" src/ returned nothing.

That matters because the release workflow freezes with pyinstaller --collect-all keboola_agent_cli, and PyInstaller's collect_all() is a
superset of --copy-metadata. The whole .dist-info is bundled, so
importlib.metadata resolves a real version inside the binary and the
0.0.0-dev fallback never fires. Both update paths therefore planned
uv tool install --force --reinstall "keboola-cli[server] @ ...", which
cannot upgrade a package-manager-owned binary -- it installs a SECOND,
unrelated kbagent into the uv tool directory, which usually precedes the
package manager's directory on PATH. The user silently starts running a
different install than the one choco/brew/apt tracks while the real binary
stays stale; with no Python on the machine it just fails on every startup.

The startup hook was only ever suppressed for frozen builds by accident: CI
freezes from an editable install, so the bundled direct_url.json carries
"editable": true and _is_dev_install() happened to return True. Freezing from
a non-editable install flips that and the reinstall fires. Both states were
reproduced against real PyInstaller binaries; kbagent update was broken in
both, since it never consulted _is_dev_install() at all.

New frozen_dist module detects sys.frozen / sys._MEIPASS and maps the running
binary's own path to the channel that placed it. The self-update stage becomes
a notification carrying that channel's real command; an unattributable path
degrades to ARCHIVE and the release page rather than a wrong-but-plausible
guess. The guard sits ahead of the issue #528 should_defer() branch, so a
frozen binary is never scheduled for the deferred Windows install either.

  • startup hook: notification throttled to once per version-cache TTL (it
    cannot resolve itself by re-exec'ing, so it would otherwise print on every
    command), and planning is short-circuited so the wheel-URL HEAD probe is not
    spent on a command we refuse to run.
  • kbagent update: refuses and names the channel, and no longer reports that
    deliberate refusal as a failed update.
  • kbagent version: advertises the channel command in place of
    "(run: kbagent update)", plus an additive install_channel key in --json
    (absent for Python installs, so their JSON shape is byte-identical).
  • keboola-mcp-server still auto-updates on frozen builds by design: it is a
    separate Python distribution the binary only spawns as a subprocess, and a
    pure-binary user with no Python is unaffected because install-method
    detection returns "none".

Verified end-to-end against a real frozen binary placed in a Homebrew Cellar
path: the hook prints "brew upgrade keboola-cli2" and the uv tool environment
is left untouched. uv / pip installs behave exactly as before.


Open in Devin Review

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

Open in Devin Review

Comment thread src/keboola_agent_cli/auto_update.py
Comment thread src/keboola_agent_cli/constants.py Outdated
Comment thread src/keboola_agent_cli/services/version_service.py
Comment thread plugins/kbagent/skills/kbagent/references/gotchas.md Outdated
Comment thread src/keboola_agent_cli/changelog.py Outdated
Comment thread src/keboola_agent_cli/commands/version.py
@padak
padak force-pushed the fix/frozen-binary-self-update branch from 8713e2f to 6492f38 Compare August 2, 2026 08:51
padak added a commit that referenced this pull request Aug 2, 2026
…in review

Six findings from Devin review of #552. The first one mattered: the feature did
not work at all in the artifacts CI actually ships.

1. The startup notification was unreachable in every released binary.
   maybe_auto_update() bails at _should_skip_all() -> _is_dev_install(), which
   reads the bundled direct_url.json. CI freezes from an editable `uv run` sync
   and --collect-all copies the whole .dist-info, so that file says
   "editable": true inside every shipped binary and the entire hook was skipped.
   My own end-to-end verification missed this because it built from a
   NON-editable install. _is_dev_install() now short-circuits on
   is_frozen_build(): the bundled marker describes the build machine, not the
   one running the binary. Covered by a test that exercises the real
   _should_skip_all() chain instead of stubbing it -- stubbing it is what hid
   the bug.

2. /usr/local/bin on Linux was attributed to apt/dnf. nfpm installs only to
   /usr/bin; /usr/local/bin is exactly where a hand-unpacked archive lands, and
   apt-get exists on essentially every Debian/Ubuntu host, so those users were
   silently handed an upgrade command for a package that was never installed.
   Dropped from NATIVE_SYSTEM_BIN_PREFIXES; the parametrized test now covers
   Linux as well as macOS.

3. The refusal summary printed "-> vNone available" when the release lookup
   failed. It now says the latest version is unknown.

4. upgrade_command held the prose hint for archive/system channels, while the
   gotchas entry tells consumers they may shell out to it. It is now
   runnable-or-empty, with the sentence in a new additive upgrade_hint key.

5. keboola-expert.md still told agents to answer version problems with
   "run kbagent update", which native binaries now refuse. Added a terse
   version-gate note and a §3 gotcha (prompt is 59632 B of the 62000 B budget).

6. The release note was one wall of text; split into four bullets, one logical
   change each, per the changelog.py authoring contract.
@padak

padak commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

All six Devin findings are addressed in 438c194 and dc4636d (the review above predates both).

# Finding Resolution
1 Reminder never appears on shipped binaries The important one. _is_dev_install() now short-circuits on is_frozen_build(). My original end-to-end verification built from a non-editable install, so it never reproduced what CI ships. Re-verified against a binary frozen from an editable uv run sync — the exact CI configuration — and the banner now prints. Test exercises the real _should_skip_all() chain rather than stubbing it, since stubbing it is what hid this.
2 /usr/local/bin misattributed to apt/dnf Dropped from NATIVE_SYSTEM_BIN_PREFIXES; nfpm only ever writes /usr/bin. Test parametrized over darwin and linux — the Linux case was the silent one, since apt-get exists on essentially every such host.
3 -> vNone available when offline Summary now says "latest version unknown" when the release lookup fails. Regression test added.
4 keboola-expert.md still says "run kbagent update" Added a version-gate note and a terse §3 gotcha. Prompt is 59632 B of the 62000 B budget.
5 Wall-of-text release note Split into four bullets, one logical change each, per the changelog.py authoring contract.
6 upgrade_command holds prose for archive/system It is now runnable-or-empty; the sentence moved to a new additive upgrade_hint key. The gotchas entry documents the contract and tells consumers to check for empty before shelling out.

Also swept the remaining convention #17 surfaces that a behaviour change in kbagent update touches but nothing gates: commands-reference.md, context.py (AGENT_CONTEXT) and the CLAUDE.md command list.

padak added 4 commits August 2, 2026 11:22
kbagent ships both as a Python distribution and as a self-contained
PyInstaller binary delivered by Chocolatey, WinGet, Homebrew, apt, dnf or a
signed zip. Nothing in the code detected the difference: `grep -rn
"sys.frozen\|_MEIPASS" src/` returned nothing.

That matters because the release workflow freezes with `pyinstaller
--collect-all keboola_agent_cli`, and PyInstaller's collect_all() is a
superset of --copy-metadata. The whole .dist-info is bundled, so
importlib.metadata resolves a real version inside the binary and the
`0.0.0-dev` fallback never fires. Both update paths therefore planned
`uv tool install --force --reinstall "keboola-cli[server] @ ..."`, which
cannot upgrade a package-manager-owned binary -- it installs a SECOND,
unrelated kbagent into the uv tool directory, which usually precedes the
package manager's directory on PATH. The user silently starts running a
different install than the one choco/brew/apt tracks while the real binary
stays stale; with no Python on the machine it just fails on every startup.

The startup hook was only ever suppressed for frozen builds by accident: CI
freezes from an editable install, so the bundled direct_url.json carries
"editable": true and _is_dev_install() happened to return True. Freezing from
a non-editable install flips that and the reinstall fires. Both states were
reproduced against real PyInstaller binaries; `kbagent update` was broken in
both, since it never consulted _is_dev_install() at all.

New frozen_dist module detects sys.frozen / sys._MEIPASS and maps the running
binary's own path to the channel that placed it. The self-update stage becomes
a notification carrying that channel's real command; an unattributable path
degrades to ARCHIVE and the release page rather than a wrong-but-plausible
guess. The guard sits ahead of the issue #528 `should_defer()` branch, so a
frozen binary is never scheduled for the deferred Windows install either.

- startup hook: notification throttled to once per version-cache TTL (it
  cannot resolve itself by re-exec'ing, so it would otherwise print on every
  command), and planning is short-circuited so the wheel-URL HEAD probe is not
  spent on a command we refuse to run.
- `kbagent update`: refuses and names the channel, and no longer reports that
  deliberate refusal as a failed update.
- `kbagent version`: advertises the channel command in place of
  "(run: kbagent update)", plus an additive install_channel key in --json
  (absent for Python installs, so their JSON shape is byte-identical).
- keboola-mcp-server still auto-updates on frozen builds by design: it is a
  separate Python distribution the binary only spawns as a subprocess, and a
  pure-binary user with no Python is unaffected because install-method
  detection returns "none".

Verified end-to-end against a real frozen binary placed in a Homebrew Cellar
path: the hook prints "brew upgrade keboola-cli2" and the uv tool environment
is left untouched. uv / pip installs behave exactly as before.
…son fix

Release audit for 0.78.0 turned up two gaps in the agent-facing docs.

Five reference files existed but nothing linked them, so the skill could never
load them: config-metadata, storage-describe, lineage-deep, permissions and
kai workflows. They are now rows in SKILL.md's workflow table (the
hand-maintained one below the auto-generated command block, so
`make skill-check` stays green).

no changelog entry and no gotcha. It changes what an agent parsing --json on
Windows sees -- the payload is no longer encoder-dependent, and JSON lines end
LF rather than CRLF there -- so it gets both: a (since v0.78.0) gotcha and a
changelog bullet under 0.78.0, the release that ships it.

Not documented here: the Apache 2.0 LICENSE added by #544. pyproject.toml,
nfpm.yaml and the Homebrew formula all still declare MIT, so which licence
kbagent ships under is a decision to settle before it is announced anywhere.
…in review

Six findings from Devin review of #552. The first one mattered: the feature did
not work at all in the artifacts CI actually ships.

1. The startup notification was unreachable in every released binary.
   maybe_auto_update() bails at _should_skip_all() -> _is_dev_install(), which
   reads the bundled direct_url.json. CI freezes from an editable `uv run` sync
   and --collect-all copies the whole .dist-info, so that file says
   "editable": true inside every shipped binary and the entire hook was skipped.
   My own end-to-end verification missed this because it built from a
   NON-editable install. _is_dev_install() now short-circuits on
   is_frozen_build(): the bundled marker describes the build machine, not the
   one running the binary. Covered by a test that exercises the real
   _should_skip_all() chain instead of stubbing it -- stubbing it is what hid
   the bug.

2. /usr/local/bin on Linux was attributed to apt/dnf. nfpm installs only to
   /usr/bin; /usr/local/bin is exactly where a hand-unpacked archive lands, and
   apt-get exists on essentially every Debian/Ubuntu host, so those users were
   silently handed an upgrade command for a package that was never installed.
   Dropped from NATIVE_SYSTEM_BIN_PREFIXES; the parametrized test now covers
   Linux as well as macOS.

3. The refusal summary printed "-> vNone available" when the release lookup
   failed. It now says the latest version is unknown.

4. upgrade_command held the prose hint for archive/system channels, while the
   gotchas entry tells consumers they may shell out to it. It is now
   runnable-or-empty, with the sentence in a new additive upgrade_hint key.

5. keboola-expert.md still told agents to answer version problems with
   "run kbagent update", which native binaries now refuse. Added a terse
   version-gate note and a §3 gotcha (prompt is 59632 B of the 62000 B budget).

6. The release note was one wall of text; split into four bullets, one logical
   change each, per the changelog.py authoring contract.
Convention #17 lists these as silent-drift risks with no CI coverage, and
`kbagent update` changing behaviour is exactly the case they exist for:

- commands-reference.md: update/version entries note the refusal and the new
  install_channel / upgrade_hint keys.
- context.py (AGENT_CONTEXT, served by `kbagent context`): same, in the
  `kbagent update` block.
- CLAUDE.md "All CLI Commands": same, next to the existing self-update notes.

check_command_sync.py stays green (251 commands).
@padak
padak force-pushed the fix/frozen-binary-self-update branch from dc4636d to 49633b2 Compare August 2, 2026 09:23
@padak
padak merged commit 0bac066 into main Aug 2, 2026
4 checks passed
@padak
padak deleted the fix/frozen-binary-self-update branch August 2, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant