Bridging external applications
Some applications MixOS runs are not MixOS's own: they are built elsewhere
and offer their own automation port. MixOS never forks them to add Bus
verbs. Instead, bridged connects to an application's control channel and
answers on the Bus for it, so agents and scripts drive it the same way as
any native app.
One bridged instance serves one application, under the application's own
name. An instance named fakeapp answers fakeapp.call,
fakeapp.commands, fakeapp.execute, fakeapp.inspect,
fakeapp.screenshot and fakeapp.status.
What the application must offer
A line-delimited JSON control channel on loopback TCP:
- Optionally, an auth line first on every connection:
{"id":"auth","method":"auth","params":{"token":"<64 hex>"}}, or withauth: "key", the same line withparams.key. A key is written by the application to a file after it starts, and may be rotated; the bridge reads that file on every connect. - Then one request per line,
{"id":N,"method":"...","params":{...}}. - One reply per line,
{"id":N,"ok":true,"result":...}or{"id":N,"ok":false,"error":"..."}, matched to its request byid.
The named verbs use the methods engine.commands, engine.execute,
ui.inspect and ui.screenshot; <app>.call passes any other method
through. The full contract is in the
bridge spec.
Setting up an instance
Write the instance config, /etc/mixos/bridge/<app>.conf.mix (or
$MIXOS_ETC/bridge/<app>.conf.mix for a session). The file name is the
instance name:
port: 47801,
token_file: "/home/user/.config/fakeapp/control.token"
For an application whose port has no auth step, write auth: "none" and no
token_file. For an application that writes its own key file after it
starts, write auth: "key" with the same token_file (the key file's path).
The instance starts before the key exists; calls answer rc 11 until it does.
Then start the unit:
systemctl start bridged@fakeapp
The unit runs mix bridged.mix --start fakeapp. It checks the instance first,
before the name joins the Bus, then runs mix --serve bridged.mix --name fakeapp as its child. A refused start exits 2, with the reason in the
journal, and is never restarted. Any other failure (a crash, a broken
install) is restarted a few times and then left failed. A normal
systemctl stop ends the bridge with status 143, which the unit counts as
clean. Run the bridge under the unit, not --start by hand: outside systemd,
killing the --start process alone can leave the serve child running. A
start is refused when:
- the Mix binary is older than 0.112.1, which cannot bound writes (
RUNTIME); - the name is not a valid Bus service name, or starts with
mixos-(NAME_INVALID); - the name belongs to the Bus broker (
noded,noded-*) (NAME_RESERVED); - the name is the first segment of a verb already registered to another
component, such as
settings(NAME_RESERVED); - the installed verb registry,
/opt/mixos/share/bus/verbs.conf.mix, is missing or unreadable (REGISTRY: the check fails closed); - the config or the token file is missing or wrong, or the token file is
readable by group or other (
CONFIG,TOKEN).
Using it
send fakeapp fakeapp.status
send fakeapp fakeapp.commands
send fakeapp fakeapp.execute body='{"command":"file.new","params":{"width":640}}'
send fakeapp fakeapp.call body='{"method":"echo","params":{"a":1}}'
| rc | Meaning |
|---|---|
| 0 | The app did it; the body is its result as JSON. |
| 10 | The app refused; the body is its error text. |
| 11 | The app could not be reached: connect, auth, transport or timeout, or the bridge is backing off after one. |
| 12 | The request was wrong: a missing field, a body that is not a JSON object, or a request longer than max_line (1 MiB by default). |
The bridge keeps one connection open. If the application goes away, calls
answer rc 11, the bridge waits backoff_s (default 2 seconds), then
reconnects and authenticates again on the next call. Each call's network
work (connect, auth, writing the request, reading the reply) shares one
timeout_s budget, so an application that stops reading, or reads too
slowly, gets rc 11 at about timeout_s and a fresh connection after the
backoff. Preparing the request (encoding a large body) cannot be cut short,
so it can add to the time a call takes beyond timeout_s; the network step
after it then fails at once. This needs Mix 0.112.1 or later.
Security
Read this before bridging an application.
- Any mesh member can drive a bridged application. The Bus gives every
member of the mesh access to every verb; there is no per-verb access
control, and
bridgedadds none. Whatever the application's control channel allows, any mesh member can do through<app>.call: send it UI input, run its commands and, if it has file commands, read and write files. Bridge only applications you would let every mesh member drive. - File access is the application's to limit. An application that offers file I/O over its control channel should confine it to automation roots it is started with (read and write directories). The bridge passes paths through unchanged and cannot confine them.
- The token stays on this node. It is read from
token_fileat start and sent only on the loopback connection. It never appears instatus, in an error or in a log line; if the application echoes it in an error or a result, the bridge replaces it with[redacted]. The token file must be0600(a file group or other can read is refused) and readable by the unit's user (mixos). - Loopback only. The config refuses any host but
127.0.0.1orlocalhost, and connects to127.0.0.1for either. Anauth: "none"port is open to every local process while the application runs, Bus or no Bus; open it only for applications you bridge. - A local process can squat the port. If the application dies, any local process that binds its port before it comes back receives the token on the bridge's next reconnect, and can then pass itself off as the application to the bridge, and use the token against the real application once it returns. Loopback TCP has no way to prove who is listening; a unix-socket control channel, checked by owner, would close this, and is a possible future protocol option. Until then, bridge applications only on machines whose local users you trust.
- The unit's environment is the administrator's. Keep
/etc/mixos/bridge/<app>.envand the config writable only by root: whoever can change them (BRIDGED_MIXin particular) picks the code that runs asmixos. - The unit is sandboxed. It runs without capabilities or new
privileges, sees the file system read-only (homes included) with private
/tmpand/var/tmp, and may send IP traffic to loopback only. So keep the config and the token file out of/tmpand/var/tmp, where the unit cannot see them. A Bus that is not on loopback (for example a session broker atws://<container-ip>:<port>) needs a drop-in,systemctl edit bridged@<app>, addingIPAddressAllow=<broker-ip>under[Service]. The loopback-only IP filter is cgroup BPF: systemd enforces it for this system unit and the serve child on kernels and hosts that support it, and ignores it where they do not (some container managers, for example). Thesystemd-analyze securityscore is computed from the unit file and does not prove the filter is enforced on a given host. - The name cannot shadow a system service. The start checks refuse a name that a registered verb already starts with, and the Bus itself refuses a name that is already registered.
Status
bridged is version 0.2.0 (unreleased); 0.2.0 adds auth: "key". Its six verbs are declared in the Bus
verb registry, docs/docs/spec/bus/verbs.conf.mix (schema 3), as the bridged
instance family <app>.<verb>. The static registry gate checks the family's
owner and suffix. Installing the registry is a separate prerequisite: the
scripts and the registry must come from the same revision, and the registry
must be at /opt/mixos/share/bus/verbs.conf.mix or named by BRIDGED_VERBS
in /etc/mixos/bridge/<app>.env. Otherwise every start is refused. This
source tree is not a live deployment, and nothing in it installs the
registry for you.
The family is declared with members: "instances", so the running
instances are not listed. tools/verb_registry.mix --instances DIR checks
only the private file-stem naming and collisions; it reads no config token.
Mesh access is unchanged: the Bus still gives every member every verb. The
runtime guard reads the verbs and families lists. It does not enforce the
full canonical format (field order, spacing); the static gate does that.
Verb classes. <app>.call and <app>.execute are intent;
<app>.commands, <app>.inspect and <app>.status are query.
<app>.screenshot is intent: its focus option can bring the window
forward, a change another caller sees, so it is not a query even though it
only captures. See the
instance verb families decision.
Source and tests
bridged is a Mix component
(decision): no Cargo
package, identity in component.mx. The source is
services/bridged:
| Path | What |
|---|---|
scripts/lib/bridge_core.mix | the stateless core: config, token, serve-name guard, line-JSON client |
scripts/bridged.mix | the Bus service (mix --serve … --name <app>); --start <app> (the unit: checks, then the service as a child); --check <app> (the checks alone) |
units/bridged@.service | one unit per application; config /etc/mixos/bridge/<app>.conf.mix |
tests/lib/fake_app.mix | a stand-in application on tcp_listen (token, key or no auth, slow and dropping methods) |
tests/bridge_core_test.mix | offline: config, token, guard, spec fixture, the client against stand-in apps |
tests/bridged_test.mix | every verb over a private broker, the start refusals, --start (no child on a refusal, the child's exit code passed through), the app dying and coming back |
Run the tests from the checkout root with Mix 0.112.1 or later. The mix
binary the tests run under is BRIDGED_MIX, else MIXOS_TEST_MIX, else
/opt/mixos/bin/mix. The Bus test needs a noded: --noded <path>, else
MIXOS_TEST_NODED, else /opt/mixos/bin/noded. An empty variable counts as
unset. tools/mix_tests.mix sets MIXOS_TEST_MIX, MIXOS_TEST_NODED and
BRIDGED_MIX to the fresh build:
mix services/bridged/tests/bridge_core_test.mix
mix services/bridged/tests/bridged_test.mix