Tools
A Tool is one package that Orbit manages on one Node through one Tool Manager. The Node, the manager, and the package name identify it. Orbit records the Tools it installs or explicitly adopts. Discovery reports other installed packages without creating Tool records.tool lists the commands.
Recording a development Instance branch rename does not install, update, adopt, or remove packages. The Gateway keeps branch inspection separate from Tool manager operations; rename does not rerun Project setup.
The annotator Process preset installs Gateway-owned server files and their verified injection asset, not a package through a Tool Manager. It creates no Tool intent and does not adopt or remove the Node’s existing Node.js runtime. Its private file publication and environment projection stay separate from Tool operations.
Where Tools run
The Gateway manages Tools only on an active Node that it manages over SSH. That Node runs a supported platform, has a verified WireGuard address, and has a stored SSH host fingerprint. Ubuntu supports the existing managers. macOS Nodes support Homebrew formulae, casks, and Vite+ global packages in the enrolled account’s existing installations. Any other Node getstool.node_inactive or tool.node_unmanaged (HTTP 409) before the Gateway changes anything.
Tool Managers do not belong to roles. A Node with no role can use every manager its platform supports. apt and composer stay Linux-only. brew and vp run on Linux and macOS. brew-cask runs on macOS only.
Tool Managers
The Gateway has five managers, fixed in code. There is no plugin registry and no generic script manager. Formulae and casks have separate manager identities, so the same package name cannot select the wrong package kind.apt and composer are not offered on macOS, and brew-cask is not offered on Linux. Install, update, removal, and adoption return tool.manager_unsupported (HTTP 422) before a lock or SSH. An absent or conflicting scope still returns tool.manager_unavailable (HTTP 409).
On Linux, composer uses COMPOSER_HOME=/opt/orbit/composer. vp scan and Linux materialization skip account-owned directories without bin/vp, such as a cache-only ~/.vite-plus/package_manager/npm directory. They choose the first Vite+ store with bin/vp for the enrolled account, in this order: /opt/orbit/vite-plus, ~/.vite-plus, then ~/.local/share/vite-plus. Install, update, removal, and scan all use that store. A later store does not change the choice. The Gateway may install a missing Linux manager on first use.
A task VM’s base image runs the Linux vp and composer materialization programs at build time. Its enrollment trusts the image and does not run those programs. Explicit Tool installs and updates do not use this enrollment skip and still run their manager commands. It still reads the manager version as usual. Every other Node runs the programs at enrollment. See Skip what the image holds.
Orbit’s /usr/local/bin Vite+ launchers export VP_HOME for every resolved store, not just /opt/orbit/vite-plus. Instance setup, teardown, and task checks also export it so project-local vp uses the same store. Tool materialization and application-role convergence publish the same launcher format. Both accept an exact, root-owned legacy launcher for that store and upgrade it with rollback on failed runtime verification; changed or foreign launchers remain conflicts.
On macOS, vp is the enrolled account’s existing Vite+ global store. The scope probe also skips account-owned directories without bin/vp. brew and brew-cask use the existing Homebrew prefix. The Gateway resolves that account’s home from the machine and does not assume /home, getent, or a Linux bottle tag. It does not install, replace, or repin those scopes, and it does not check out a Homebrew revision.
A missing scope is scan state absent. A scope with the wrong owner, a non-official Homebrew origin, or a dirty Linux Homebrew tree is scan state conflicting. On macOS, two Vite+ stores with bin/vp, or one store with the wrong owner or shape, is conflicting. On Linux, the chosen Vite+ store is conflicting when it is a symlink, not a directory, owned by someone else, or its present bin/vp is not executable or is owned by someone else. A second Linux store is not a conflict. Adoption, install, update, and removal then return tool.manager_unavailable (HTTP 409). Orbit does not repair that scope.
An accepted macOS Homebrew prefix is owned by the enrolled account and has origin https://github.com/Homebrew/brew. Apple silicon and an untar-anywhere install are the git repository: prefix/.git exists and prefix/bin/brew is a regular executable. The Intel /usr/local layout keeps the nested repository prefix/Homebrew/.git and a prefix/bin/brew symlink to ../Homebrew/bin/brew. Any other shape is conflicting.
The probe prints the prefix and the account home. The script accepts only /, ASCII letters, digits, ., _, and - in that home. It stops when the home contains .. or any other character, including a space, @, +, or a non-ASCII letter, so the probe fails and no prefix is accepted. A home of / is ignored after a successful probe: the prefix stands and the home is unknown. A cask target is user-owned only when the home is known and contains the target. A font under that home, such as font-fira-code, is supported. A cask that needs an administrator prompt, such as stats, stays authorization_required.
orbit tool:manager:list --node=<id> shows each manager the Node’s platform supports, with one of these states. An unsupported manager is omitted, not listed as uninstalled.
Before each Linux install, the Gateway converges the manager: it installs the manager when it is missing and checks its version. A failure returns
tool.manager_provision_failed (HTTP 502), keeps the manager failed, and creates no Tool. On macOS, install rechecks the existing scope and returns tool.manager_unavailable (HTTP 409) when that scope is absent or conflicting. It creates no Tool. An absent macOS scope stays failed at step manager-absent with error code node.tool_manager_absent. A conflicting owner, origin, or Vite+ store stays failed at step manager-conflict with error code node.tool_manager_conflict. A failed macOS probe returns tool.manager_provision_failed.
Update and removal use the same tool.manager_unavailable code when the macOS scope is absent or conflicting. The Tool stays failed for that operation, with its installed version kept, and the same command retries it. A probe that is not an absent or conflicting scope still returns tool.version_probe_failed, tool.update_failed, or tool.remove_failed. A manager stays installed after its last Tool is removed. No command removes a manager.
The dpkg lock
On Linux, every apt command in the table passes-o DPkg::Lock::Timeout=300. On Ubuntu 26.04, apt 3.2.0 applies that timeout only while it takes the dpkg frontend lock and the dpkg admin lock. Those locks are /var/lib/dpkg/lock-frontend and /var/lib/dpkg/lock. apt-get install and apt-get remove take them, so they wait up to 300 seconds. The apt manager’s update is apt-get install, so it waits too. A daily upgrade, such as apt-daily-upgrade, can hold one of those locks. Install and remove wait for that upgrade to release the lock instead of failing at once.
Caddy and PHP package installation pass this same option. Caddy uses the shared package step for role prerequisites and publication. Prerequisite apt-get update passes it and does not use it. Update takes the lists lock, /var/lib/apt/lists/lock, and fails at once when that lock is busy.
Version probes,
apt-cache policy, and the removal plan apt-get --simulate remove do not pass the option and do not wait.
When a dpkg frontend or admin lock is still held after 300 seconds, install or remove exits with an error and changes no package. That covers a brew or composer prerequisite install, and the apt manager’s install, update, and remove. A prerequisite apt-get update does not reach this timeout. A busy lists lock makes that update fail at once.
Either failure on tool:install returns tool.manager_provision_failed (HTTP 502). The manager stays failed at step materialize with error code node.tool_manager_materialization_failed, and Orbit creates no Tool. Retry the install.
A composer prerequisite failure during app-dev or app-prod convergence leaves the manager failed at the same step and error code. The role or node:add reports node.tool_manager_materialization_failed in details.error_code. Role operations on one Node describes how a failed role reports its step.
An apt install, update, or remove returns tool.install_failed, tool.update_failed, or tool.remove_failed (HTTP 502). The Tool stays failed. Update and removal keep the recorded version. Retry the same command.
Discover installed packages
GET /api/v1/tool-inventory?node_id=<id> is tool:scan. The CLI command is orbit tool:scan --node=<id>. The only input is node_id, a strict integer of an existing Node. Any other query or body field fails with validation.failed (HTTP 422). This operation is not a script API.
The scan reads brew, brew-cask, and vp for the enrolled account. It does not inventory apt or composer. It compares packages with Tool rows by Node, manager, and package. It stores nothing and creates no Tool row. It does not take a manager lock and it does not materialize a manager. A failed, malformed, or truncated read marks that manager incomplete and is never an empty complete inventory. The other managers still return their own state.
The Gateway resolves the host itself and uses the pinned SSH identity. The response has no raw output, paths, environment values, checksums, or URLs. Success is HTTP 200.
Packages inside a
complete manager are ordered by package name, ascending. A formula and a cask that share a name stay on different managers.
The block tokens are
protected, dependency, unsupported_artifact, unsupported_source, bottle_unavailable, version_unreadable, and authorization_required. A dependency or a protected package is never supported. An unsupported cask stays in the list with adoption unavailable and one of those tokens. Doctor and the Node’s Tools page use this inspector. See informational package discoveries.
The scan stores a normalized SemVer version, or null. A readable formula revision such as 25.8.1_1, and a readable cask version such as 3.003, normalize to null and can still be supported. version_unreadable means there is no readable version string, such as latest. It does not mean readable text failed the SemVer check. tool:scan prints a null version as an em dash. The Tools page shows unreadable. Doctor prints version=unknown.
Homebrew inventory uses two reads for each of brew and brew-cask. info --json=v2 --formula --installed and info --json=v2 --cask --installed supply metadata. list --formula -1 and list --cask -1 supply installed names without loading formulae or casks. Both use the usual Homebrew environment and do not set HOMEBREW_FORCE_API_AUTO_UPDATE.
Homebrew can omit an installed formula or cask from info and still exit 0, including when the tap is untrusted or Homebrew refuses to load it. The name stays in list. The scan keeps that name, with installed_version null and dependency false, because the name list does not say whether it is a dependency. Adoption is unsupported. The block is unsupported_source, or protected when the formula name is protected.
A name present in info but absent from list makes that manager incomplete. A failed, malformed, or truncated name list also makes that manager incomplete. The scan does not report the info packages alone.
Vite+ reports each global root under the name it stored. A stored name that Orbit cannot manage, including a legacy npm name with capitals such as JSONStream, stays in that list. Its adoption is unsupported, its block is unsupported_source, and it is not registered. The vp scan is incomplete when a name is not a string, is empty, is longer than 255 characters, contains a control character, or appears twice. Those cases are not an inventory.
Scan errors use the Tool error envelope. details.step is scan, details.outcome is manager_failed, and there is no Tool id. A manager state of incomplete, absent, unsupported, or conflicting does not fail the HTTP request.
Scan reads installed names and versions only. It does not run
brew update, and it does not set HOMEBREW_FORCE_API_AUTO_UPDATE. A stale Homebrew API cache does not by itself make the scan incomplete.
Protected packages
Some installed packages keep SSH, the WireGuard tunnel, DNS, Docker, the firewall, sudo, Caddy, or a manager working. Adopting one would lettool:remove uninstall it. These names are protected. The set is closed and depends on the manager.
The apt names are every package
NodeBootstrapPackageCatalog returns from forNode and forRole, plus openssh-server and wireguard-tools. The Gateway reads that catalog at the check. It does not keep a second copied list. The table is that union.
composer and brew-cask have no protected names. The apt package composer is protected. The composer manager is a different scope and has none. A protected package still appears in scan, with adoption unsupported and adoption_block protected. Install, update, and adopt refuse it with no new Tool row. Install and update return tool.package_protected (HTTP 409, outcome manager_failed). Adopt returns tool.adoption_unsupported with that block token. tool:remove of an existing row returns tool.package_protected, leaves the row, and does not run the uninstaller. tool.removal_plan_unsafe does not replace this list. A plan that removes only docker.io or only dnsmasq is still refused.
Adopt a Tool
POST /api/v1/tools/adopt is tool:adopt. The CLI command is orbit tool:adopt <package> --node=<id> --manager=<manager> [--constraint=<range>] --yes. MCP and the Tools page call this operation. They do not get a second adoption API. Adoption installs, updates, removes, and repins nothing.
The JSON object allows only these fields. Any other field fails with validation.failed (HTTP 422).
The Gateway takes the Tool lock for that Node, manager, and package, then the manager scope lock.
brew-cask uses the brew prefix lock. It then reads the live package again. A discovery row is not evidence.
Success returns the same Tool object as install, update, and remove. status is installed. failed_operation and error_code are null. outcome is set. The object is wrapped in data, with meta.request_id.
A different constraint on an existing Tool is
tool.constraint_conflict. It does not create a second row. A row left in installing, updating, or removing is not repaired. Adopt returns tool.state_invalid and leaves that row. The caller retries the original operation or removes the Tool.
Adoption errors use the Tool envelope. details.step is adopt. details.id is included only after the Gateway has found an existing Tool row. tool.adoption_unsupported also includes details.adoption_block.
details.adoption_block uses the scan block tokens. brew adoption requires an unqualified Core formula with a compatible verified bottle, so a later update has a supported path. brew-cask adoption requires official metadata, a supported artifact, and a checksum, and it refuses a cask that needs an interactive or administrator prompt to upgrade or remove. vp adoption requires a root package in the enrolled account’s global scope. apt and composer can be adopted only on Linux, in the scopes above. No adoption takes dependencies or any other package.
Install still refuses an unregistered installed package with tool.already_installed_unmanaged (HTTP 409). It does not adopt that package. The caller uses tool:adopt.
On Linux, unconstrained adoption records the apt version string, such as 5.8.3-1. A constraint is checked only when that version normalizes to SemVer. ^1.25 accepts 1.25.0-2ubuntu4. A version that does not normalize returns tool.installed_version_unparseable and creates no Tool row.
Install a Tool
A caller sends only the Node, the manager, the package name, and an optional version constraint. Each manager checks the package name against its own grammar and builds a fixed command with the name in one argument position. A caller cannot send commands, options, repositories, or environment values. The Gateway refuses a package that is already on the Node without a Tool record, withtool.already_installed_unmanaged (HTTP 409). Use explicit adoption to take ownership.
A new install creates the Tool as installing, installs the package, and reads the installed version. A success marks the Tool installed and reports applied. A failure after the Tool exists marks it failed and returns its ID in the error, so you can retry or remove it. Running the same install again retries a Tool whose install failed. An install of a Tool that is already installed, with the same constraint, checks the package again. When the package is present, the result is unchanged.
The optional constraint is a SemVer range, such as ^0.150. It only stops an unsafe version. Before an install, the Gateway reads the manager’s candidate version. A candidate outside the range records the Tool as failed with tool.version_constraint_blocked (HTTP 422, outcome blocked_by_constraint) and installs nothing. Remove that row, or retry with a constraint the candidate satisfies. The Gateway never searches for another matching version and never downgrades. When the candidate is not SemVer, the same failed row is kept and the code is tool.candidate_version_unparseable. A Tool keeps its constraint: installing it again with another constraint fails with tool.constraint_conflict.
Update a Tool
tool:update asks the manager for its current candidate and installs it. The result is applied when the version changed and unchanged when it did not. When the candidate falls outside the stored constraint, the update changes nothing and reports blocked_by_constraint. The Tool stays installed. A Homebrew formula that is pinned stays pinned. Update reports unchanged and does not clear the pin. If the macOS Homebrew prefix or Vite+ scope is absent or conflicting, update returns tool.manager_unavailable (HTTP 409), marks the Tool failed, and leaves the recorded version in place.
Homebrew formulae
Thebrew manager accepts one lowercase formula name from Homebrew Core, without a tap prefix. The name may end in +, as in libsigc++ or gtk+. Before an install or update, the Gateway reads the formula’s metadata. It requires the homebrew/core tap, a stable version, and a compatible bottle with a SHA-256 checksum. Homebrew installs that bottle with --force-bottle. Orbit never builds a formula from source. Inventory uses info --json=v2 --formula --installed and list --formula -1.
Orbit never runs a Homebrew developer command. That includes brew ruby and brew irb. A developer command writes homebrew.devcmdrun into the user’s Homebrew git config and changes how a later brew update behaves. Orbit does not set HOMEBREW_DEVELOPER or HOMEBREW_DEV_CMD_RUN.
The compatible tag is computed in the Gateway. On macOS it reads the product version with the fixed command sw_vers -productVersion and uses the CPU stored from uname -m. A code-owned table maps that version to Homebrew’s active bottle symbol. The Gateway does not store the OS version on the Node.
When the major is 11 or higher, Orbit uses that major. It does not turn the display alias
10.16 into big_sur. A version with no row, including major 10, has no compatible bottle. Apple silicon uses arm64_ plus the symbol, so macOS 27 is arm64_golden_gate. Intel macOS, x86_64, uses the symbol alone. Any other macOS CPU has no compatible bottle. Update this table when Homebrew adds a symbol. Until then that OS has no compatible bottle.
Linux does not use the table. x86_64 maps to x86_64_linux. aarch64 and arm64 map to arm64_linux.
A bottle file keyed by that tag is compatible. A file keyed all is compatible only when the current tag is absent. An older OS tag, such as arm64_sequoia on macOS 27, is not compatible. If neither the current tag nor all has a checksummed file, the formula is refused before any change. Adopt uses block bottle_unavailable.
On Linux, the Gateway installs Homebrew at a pinned revision. When the prefix already exists, the Gateway accepts it only when the managed user owns it, its origin is the official Homebrew repository, and its working tree is clean. It then checks out the pinned revision. Otherwise it leaves the prefix unchanged and marks the manager
failed. Tool operations never start or stop a Homebrew service.
On macOS, Orbit verifies and reuses the existing Homebrew installation without checking out another revision and without running brew update. Formula operations still force compatible bottles. Updating one root formula may install or upgrade the dependencies that formula requires. It does not upgrade other root packages or dependents that merely depend on it.
Every brew and brew-cask command sets HOMEBREW_NO_AUTO_UPDATE, HOMEBREW_NO_ANALYTICS, HOMEBREW_NO_ENV_HINTS, HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK, and HOMEBREW_NO_INSTALL_CLEANUP. Those last two stop install and upgrade from upgrading unrelated packages or running periodic cleanup. Scan does not set HOMEBREW_FORCE_API_AUTO_UPDATE. Install, update, and the adopt bottle check on macOS do set it, so Homebrew can refresh its API data while HOMEBREW_NO_AUTO_UPDATE still blocks a git update of the user’s Homebrew revision. Linux keeps the pinned revision and does not force that API refresh. Its metadata freshness stays the behavior already shipped with the pinned prefix.
Homebrew casks
brew-cask supports official Homebrew casks on macOS. The caller sends one unqualified token. A token may end in +, as in logi-options+. The Gateway turns that into the fixed coordinate homebrew/cask/<token> and refuses a tap, a URL, a local file, or a caller option before it runs anything. A formula and a cask with the same token stay different Tools, because the manager is part of the identity.
Before install or update, the Gateway reads brew info --json=v2 --cask for that coordinate. The cask must come from the homebrew/cask tap, use an https URL, and name itself with the same unqualified token. The Gateway then applies one refusal, in this order: an unsupported artifact, interactive or administrator authorization, the checksum policy, then an unreadable version.
brew info --json=v2 returns the current arm64 definition as the base fields. Other Mac definitions are shallow overrides under variations, keyed by the same bottle tag formulae use (sw_vers plus the code-owned symbol table; arm64 prefixes the symbol, Intel uses the symbol alone). The Gateway applies variations[<tag>] before it checks the URL, version, checksum, and artifacts. sha256 must be one lowercase SHA-256 on that definition. no_check fails the checksum policy. Discovery reports that checksum failure as unsupported_artifact.
Supported artifacts install into the enrolled user’s home or the Homebrew prefix. That includes fonts, binaries, man pages, completions, and user plugins whose target is /$HOME, ~/, or $HOMEBREW_PREFIX. brew info --json=v2 expands a default home target to that account’s absolute home, such as /Users/mini/Library/Fonts/Hack.ttf. The same expansion applies to uninstall trash, delete, and rmdir paths. An absolute path inside the resolved home is still the user’s home.
An app or suite that targets /Applications, another account’s home, a package installer, a privileged plugin, or an uninstall that uses pkgutil, kext, launchctl, or a system path is authorization_required. Preflight, postflight, stage-only, and arbitrary uninstall scripts are unsupported_artifact. A disabled cask is too. Zap stanzas are ignored and never run.
Removal does not repeat the disabled, checksum, version, or source gates. It still refuses an arbitrary uninstall script, a package installer, an app in /Applications, or any other uninstall that needs interactive or administrator authorization. A disabled, unchecksummed, or latest cask can still be removed when that uninstall is safe.
When the official cask is gone, Homebrew says homebrew/cask/<token> is unavailable because the tap is not installed. Removal then reads that token from info --json=v2 --cask --installed. It uninstalls only when that entry’s tap is homebrew/cask, and it passes the plain token to uninstall --cask. The same bottle-tag override applies.
Install and update use install --cask and upgrade --cask for the coordinate. Removal of an available cask uses uninstall --cask for that coordinate. Inventory uses info --json=v2 --cask --installed and list --cask -1. A cask that list names and info omits stays in discovery with unsupported_source.
The installed-version read uses list --versions --cask with the plain token. Homebrew prints the token and version when the cask is installed. When it is absent, that command exits 1 and prints nothing. A silent exit 1 is not absence by itself. The Gateway then reads info --json=v2 --cask --installed. It reports no installed version only when that token is missing, or when the entry’s tap is not homebrew/cask.
The Gateway runs brew over a non-TTY SSH session with BatchMode=yes, so sudo cannot prompt. Install, update, and the metadata read that guards them also set HOMEBREW_FORCE_API_AUTO_UPDATE. Inventory, removal, and installed-version reads do not. The Gateway never runs brew uninstall --zap, brew autoremove, brew upgrade without the cask, brew services, or a Homebrew developer command.
An unsupported installed cask stays in discovery with adoption unsupported and one block token. Discovery does not create a Tool and does not mean the cask can be adopted. A supported cask can be installed, updated, and removed as a normal Tool. A policy refusal or a failed command returns that operation’s own error: tool.install_failed, tool.update_failed, or tool.remove_failed (HTTP 502). The Tool stays failed and can be retried. The error never includes raw Homebrew output. Adoption of a cask that needs an interactive or administrator prompt uses tool.adoption_unsupported with adoption_block authorization_required. Formula and cask operations share the Homebrew prefix lock, including when their manager records have different ids.
Remove a Tool
tool:remove removes an installed or failed Tool. Every success returns the Tool object with status removed and outcome applied. This covers an actual removal, a package that was already absent, and a failed Tool with no probed version. removed appears only in that response. It is never stored, so list and show never return it.
The Gateway first reads the installed version. For apt, it then plans the removal with apt-get --simulate remove and refuses a plan that removes any other package, with tool.removal_plan_unsafe. It removes only the recorded package and never runs an autoremove. After the removal, it reads the version again. A Tool whose package is gone is deleted.
apt removes a package without purging its configuration files. The Gateway treats a package that dpkg lists as removed with only its configuration left as absent.
Retry the same command with the Tool ID. The Gateway reads the live package state before it acts again.
Operation outcomes
Every Tool success body and every Tool errordetails.outcome uses one of these tokens.
Install returns
blocked_by_constraint only inside tool.version_constraint_blocked, and only before it installs a candidate. Adopt does not use that code. An installed version that falls outside the constraint is tool.installed_version_constraint_violated for both install and adopt.
Errors
A Tool error carries a stablecode, a message, and details. The Gateway never stores or returns the raw output of a package manager.
No other
details keys are returned.
Locks
Each mutation locks its Tool and its manager’s scope on the Node. A busy lock fails at once withtool.operation_locked. brew and brew-cask share the Homebrew prefix lock. Scan does not take either lock. Per-Node locks lists every lock and its term.
tool.operation_locked means Orbit’s lock is busy. It does not mean a dpkg lock is busy. The dpkg lock says which apt commands wait for one.
Check removal with Doctor
Thetool family of Doctor compares each Tool record with the Node. A record whose package is absent reports tool.not_installed, including a failed install that never placed the package. A normalized version that violates the stored constraint reports tool.version_mismatch. An unconstrained Tool needs only to be installed; the recorded version is the last operation’s result, not desired intent. The report never contains raw dpkg output. Those drift issues use the Tool id as resource_id and leave resource_name null. Unregistered discoveries are a separate informational kind. They do not replace these drift checks.
Why it works this way
These reasons explain the design. Check them before you propose a change.A closed manager registry
Each manager is code with its own grammar, fixed commands, and tests. So the Tool API cannot run an arbitrary command. Package plugins, per-Tool definitions, generic scripts, and caller-supplied options are rejected alternatives. A new manager needs a new adapter in code.Explicit package ownership
An installation alone does not prove Orbit ownership. Discovery shows what exists; installation or explicit adoption establishes what Orbit may manage. Automatic adoption would turn a read into permission to update or remove unrelated software. Tool rows therefore hold selected intent, not host inventory. Orbit removes only the exact package it recorded, without autoremove.Managers on demand, independent of roles
A manager serves any Node that the Gateway manages. Tyingvp and composer to application roles would make Tools depend on where applications run, and role removal would have to handle unrelated Tools. Installing every manager on every Node is also rejected, because most Nodes need few of them and would carry needless software. apt and composer stay Linux-only. vp uses the enrolled account’s global scope on Linux and macOS. macOS reuses that scope and the existing Homebrew prefix instead of installing a second copy.
Bottled Homebrew Core only
The formula manager accepts only Homebrew Core bottles with verified checksums, on Linux and macOS. The macOS tag comes fromsw_vers and the code-owned symbol table, never from a Homebrew developer command. Casks use a separate macOS adapter with official metadata and supported artifacts. Taps, source builds, caller options, and arbitrary installers remain outside both contracts. Separate package identities avoid formula and cask collisions; a shared prefix lock prevents concurrent mutations of their common installation.
Selected adoption
Discovery shows installed packages and creates no Tool rows. Adoption is the separate choice to own one supported package that is already installed. It does not install, update, remove, or repin the manager. The CLI, MCP, and the Node Tools page call that same Gateway operation. Owning every installed package was rejected because a scan is not a request to own the machine. Silent adoption during install was rejected because installing and taking ownership are different intentions. Storing discoveries as Tools was rejected because a Tool row is selected intent, not an observation. One identity for a formula and a cask was rejected because the names can collide and the operations differ. Separate locks were rejected because both change the same Homebrew prefix. Adopting tunnel and bootstrap packages was rejected because removal would then uninstall SSH, WireGuard, DNS, Docker, the firewall, sudo, Caddy, or a manager. A readable version that is not SemVer can still be adopted when no constraint is set. A formula revision such as25.8.1_1 and a cask version such as 3.003 are null in the scan. Doctor shows unknown, the Tools page shows unreadable, and tool:scan shows an em dash. A constraint cannot be checked, so adopt returns tool.installed_version_unparseable and install returns tool.candidate_version_unparseable. The package stays where it was.