Apply [bootstrap.packages] at provisioning time via a root package applier #274
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#274
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
A project's mise config declares system packages under
[bootstrap.packages](e.g."apt:libwireplumber-0.5-dev" = "latest"), and the deck never installs them. Agent sessions in such a workspace have no headers, no.pcfiles and no docs for the project's C/C++ libraries, and cannot verify a library API at all.Verified in this repository and in a workspace container:
apps/server/src/mise/provision.ts:142-175spawns exactly["mise", "install", "--yes"]in the scratch root.mise installapplies[tools]; per mise's documentation it only prints a one-time hint about missing[bootstrap.packages]and exits 0. A repo-wide search forbootstrapfinds nomise bootstrapinvocation anywhere.runProvisionreportsphase: "ok"(provision.ts:207-209). The Workspaces box (apps/web/src/components/settings/WorkspacesPanel.tsx:390-396) and the per-turn status line (apps/server/src/mise/system-context.ts:47-56) both say the workspace is fine. The failure is silent by construction.docs/configuration.md:257-279documents the provisioning contract purely in terms of[tools];[bootstrap.packages]is not mentioned.mise bootstrapexists in the pinnedMISE_VERSION=v2026.11.7(Dockerfile:96). Its apt manager usessudowhen mise is not running as root;--yesdoes not supply a sudo password. A session runs as uid 1000 with nosudoin the image (Dockerfile:128-149), so nothing a session can do installs these packages.docker/entrypoint.sh:17requires the container to start as root and drops both real processes withsetpriv(:38-43, :79, :93), so a root context exists only in the entrypoint itself.The deck must therefore install the declared system packages itself, at runtime, through a root-side component.
Plan
Context
Add a root-privileged applier process to the container that applies a workspace's
[bootstrap.files]and[bootstrap.packages]with mise, driven by the deck's existing background provisioning. The deck asks it over a unix socket; where no socket exists (bare-metal and development deployments) the deck runs the same sequence itself. The outcome is reported separately from the tools install, in the Workspaces UI and in the agent's per-turn status line, so a workspace whose libraries are missing is visible instead of green.Accepted property, to be stated in the docs change below: the applier has no policy gate — it applies whatever the workspace's mise config declares. The agent runs as the deck's UID and has write access to the workspace checkout and its
.git, so it can cause arbitrary apt maintainer scripts, and[bootstrap.files]templates (which may callexec()), to run as root. Root in this container can read$OMP_DECK_DATA_DIR/broker/config.json(mode 0600, uid 999), which holds the Forgejo site-admin token and the webhook secret. The UID separation indocker/entrypoint.sh:1-9, 47-72therefore stops being a boundary against the agent and becomes advisory. This is a deliberate choice, not an oversight; do not add a gate as part of this work.Only the packages and files parts are ever applied. A full
mise bootstraprun additionally applies services, firewall rules, compose projects, repos, dotfiles, systemd units and the login shell, and runs the config's hooks andbootstraptask — the latter being arbitrary root command execution. Both parts are therefore invoked through their own subcommands (mise bootstrap files apply,mise bootstrap packages apply), never throughmise bootstrap --only …, which permise bootstrap --helpruns hooks and thebootstraptask on every invocation.Approach
Steps are ordered so the tree builds and the existing suite passes after each.
1. Shared apply module:
packages/mise-applyNew workspace package
@omp-deck/mise-apply(packages/mise-apply/{package.json,tsconfig.json,src/index.ts}), mirroringpackages/log's manifest shape ("type": "module","main": "./src/index.ts",typecheck/testscripts, version0.6.1). It is the single place that constructs and runs mise commands, imported by both the applier daemon and the deck's fallback path, so the privileged command construction is not duplicated.Exported API:
Behaviour of
applyBootstrap(root), withrootan existing directory holding mise configs:mise bootstrap packages status --jsonandmise bootstrap files status --jsoninroot. A non-zero exit from either is a failure (ok: false, stderr tail aserror).declaredis true when either inspection reports at least one entry. When false, return{ declared: false, ok: true, applied: [] }without running anything else.mise bootstrap files apply --yes. On non-zero exit, return the failure without attempting packages — a repository definition that failed to land makes the package install meaningless.mise bootstrap packages apply --yes.mise bootstrap packages status --missing, whose documented contract is exit 1 when any package still differs from the config. Non-zero meansok: falsewith the command's own output aserror, even if step 4 exited 0.appliedis the package keys read from the step-1 JSON. Parsing is best-effort (see Assumptions): on any parse failure, leave it empty and keep the phase from the exit codes.Every spawn uses
Bun.spawnwithcwd: root,stdin: "ignore",stdout: "ignore",stderr: "pipe"(the shape already used inprovision.ts:142-175), a per-command timeout of15 * 60_000ms enforced withsetTimeout+proc.kill(), and this environment on top ofprocess.env:MISE_CEILING_PATHS=path.dirname(root)— same reason asprovision.ts:149-151: mise's walk up the tree must not pick up stray config from the data dir.MISE_TRUSTED_CONFIG_PATHS=rootandMISE_YES=1— the config is owned by the deck's UID while mise runs as root; see Assumptions for the fallback if mise still refuses it.Applies are serialised process-wide with a module-level promise chain (
let queue: Promise<unknown> = Promise.resolve()), because the dpkg lock is machine-wide and two concurrent workspaces would otherwise fail each other.Unit tests (
packages/mise-apply/src/index.test.ts) exercise the sequence through an injected spawn implementation (add an internal, non-exported-by-default seam — a second optional parameter onapplyBootstraptyped as the spawn function — rather than mocking Bun globally): nothing declared → no apply commands run; files declared →files applyprecedespackages apply; files apply failing → packages apply never runs;status --missingexiting 1 after a successful apply →ok: false.2. Protocol: a separate packages sub-status
In
packages/protocol/src/index.ts, besideMiseStatus(:60-70):and add to
MiseStatus:Absent-when-nothing-declared is deliberate: existing workspaces gain no new UI noise or status-line text.
3. The applier daemon:
apps/packagesNew workspace app
@omp-deck/packages(apps/packages/{package.json,tsconfig.json}), manifest copied fromapps/broker/package.jsonwith@omp-deck/mise-applyadded todependencies.apps/packages/src/index.tsmirrorsapps/broker/src/index.ts:27-40:process.env.OMP_PACKAGES_SOCKET?.trim() || "/run/omp-packages/packages.sock";fs.rmSync(socket, { force: true }),Bun.serve({ unix: socket, fetch: createHandler() }),fs.chmodSync(socket, 0o666)— the same choice the broker makes and for the same reason stated explicitly in a comment: with no policy gate the route is no more powerful for the agent than for the deck, and the request carries no secret;SIGTERM/SIGINTshutdown that stops the server and removes the socket;OMP_DECK_DATA_DIRis required: log an error andprocess.exit(1)when it is unset, because the scratch root cannot otherwise be derived.apps/packages/src/server.tsexportscreateHandler(apply = applyBootstrap):POST /apply→404 {"error":"not found"}.{ "workspaceId": string }. Reject with400 {"error":"invalid request"}when it is not a string or does not match/^[A-Za-z0-9_-]{1,64}$/. The id is never joined into a path before this check passes: the path is derived here, not taken from the wire, so a traversal attempt cannot escape the data dir.path.join(process.env.OMP_DECK_DATA_DIR, "mise", "workspaces", workspaceId). When it does not exist, answer200 {"declared":false,"ok":true,"applied":[]}— the deck has not provisioned that workspace, which is not an error.200with theBootstrapApplyResultas JSON.Tests in
apps/packages/src/server.test.ts, followingapps/broker/src/server.test.ts: route and method rejection, invalid and traversal-shaped workspace ids, missing scratch root, and pass-through of a failing result.4. Deck client with direct fallback
New
apps/server/src/mise/packages-client.ts, modelled onapps/server/src/forgejo/broker-client.ts:16-47(failures returned, never thrown):applyWorkspacePackagescallsfetch("http://packages/apply", { unix: packagesSocketPath(), method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ workspaceId }), signal: AbortSignal.timeout(16 * 60_000) }). The timeout exceeds the module's own 15-minute per-command cap so the daemon's error wins over a client timeout.When the socket is absent or the connection fails, fall back to calling
applyBootstrap(root)in-process (add"@omp-deck/mise-apply": "workspace:*"toapps/server/package.json). There mise runs as the deck's own user, so its apt manager elevates throughsudo; without a tty and without NOPASSWD that fails immediately and is reported like any other failure. A non-200 response, or a response body that is not a well-formedBootstrapApplyResult, is a failure with the status text aserror— it is not retried through the fallback, since the daemon answered.5. Wire it into provisioning
In
apps/server/src/mise/provision.ts, insiderunProvision(:177-214), between the scratch root being filled andrunInstall::201-204(no sources and nothing pasted →idle): a workspace with no config at all still declares nothing.packages: { phase: "running", applied: [] }on the status, thenawait applyWorkspacePackages(ws.id, root).packages: MisePackagesStatus | undefined—undefinedwhendeclaredis false, otherwise{ phase: result.ok ? "ok" : "failed", applied: result.applied, error: result.ok ? undefined : result.error }— and log a warning on failure in the style of:212.setStatuscall inrunProvisionso the packages outcome is not dropped when the tools install finishes.mise install: a tool that compiles from source commonly needs the declared build dependencies to exist first.The
failedbranch inprovisionWorkspace(:224-232) andsetStatus(:66-68) need no signature change —packagesrides along in theOmit<MiseStatus, "updatedAt">argument.6. Tell the agent, and the operator
apps/server/src/mise/system-context.ts:GUIDANCE(:19-39) currently opens "Dev tools in this container come from mise, not from apt… anything apt installed would be lost when the container is recreated", which this change makes wrong. Rewrite that opening paragraph to say that system packages come from the project's mise config ([bootstrap.packages], plus[bootstrap.files]for repository definitions), which the deck applies for the workspace, and that the session itself still has no root and cannot install anything with apt — a library the project needs belongs in that config. Keep every existing bullet about shims,mise which,mise exec,[env]and global installs unchanged.miseStatusLine(:47-56) gains, only whenstatus.packagesis present:ok:System packages are installed: <applied joined by ", ">.(omit the list whenappliedis empty).running:System packages are still being installed; their headers may not exist yet.failed:System packages could not be installed: <error> Do not guess at the API of a library whose headers are missing — say that it is unavailable.idle: nothing.apps/server/src/mise/system-context.test.tscovering those four cases plus the absent case (no new sentence).apps/web/src/components/settings/WorkspacesPanel.tsx: inMiseCell, below the existing phase line (:390-393), render a secondtext-2xsline whenws.mise.packagesis present —packages: <phase>plus the applied keys joined with,— and, when that phase isfailed, its error in the samefont-mono text-2xs text-red-400style the tools error already uses (:394-396).docs/configuration.md§"Dev tools (mise)" (:257-279): add that the deck applies the workspace's[bootstrap.files]and[bootstrap.packages]through a root-side applier before installing tools, that only those two parts are applied (no hooks, nobootstraptask, no services/firewall/dotfiles/login shell), that the installs live in the container layer and are therefore re-applied after every container recreation, and — explicitly — that a workspace's mise config can thereby run code as root in the container, which means the broker's UID separation no longer withholds its secrets from the agent.docs/deployment.md, beside the broker section it already has, documents the new process,OMP_PACKAGES_SOCKET, and its socket directory.7. Container wiring
docker/entrypoint.sh, before the broker is started at:79:It runs as root — no
setpriv— which is the whole point; say so in a comment next to it. AddOMP_PACKAGES_SOCKETto the deck's environment so client and daemon agree. Extend the existing supervision: include$PACKAGESin thetrapat:96and in the shutdown handling at:110-113. Do not add it to thewhile kill -0loop at:99-101: the applier dying must not take the container down, since the deck degrades to its fallback path and reports the failure.Dockerfile: addCOPY apps/packages/package.json apps/packages/andCOPY packages/mise-apply/package.json packages/mise-apply/next to the existing manifest copies (:181-185), andCOPY apps/packages apps/packages/COPY packages/mise-apply packages/mise-applynext to:191-194.Adding two workspace members changes the lockfile, and the image builds with
bun install --frozen-lockfile(:188): runbun installat the repo root once after the manifests exist and commitbun.lock.For the published npm package,
@omp-deck/mise-applymust be bundled exactly asprotocolandlogare: add"@omp-deck/mise-apply": "0.6.1"to the rootpackage.jsondependenciesand tobundledDependencies, and add"mise-apply"to theBUNDLEDarray inscripts/prepack.mjs:39. Without this the bare-metal fallback path fails to resolve at runtime.apps/packagesis not published; it exists only in the image.Critical files & anchors
apps/server/src/mise/provision.tsrunProvision,:177-214apps/broker/src/index.ts+apps/server/src/forgejo/broker-client.ts:27-40/:16-47docker/entrypoint.sh:74-101scripts/prepack.mjsBUNDLED,:39apps/server/src/mise/system-context.tsGUIDANCE:19-39,miseStatusLine:47-56Verification
Prerequisites: repo root,
bun installalready run.bun run typecheckandbun test— the whole suite, including the newpackages/mise-apply,apps/packagesandsystem-contexttests./dataasdocs/deployment.mddescribes.mise.tomlcontains:packages: running, thenpackages: ok · apt:libwireplumber-0.5-dev; the session's per-turn context containsSystem packages are installed: apt:libwireplumber-0.5-dev.; andls /usr/include/wireplumber-0.5inside that session lists headers. Before this change the same setup reportsokwith no packages line and the directory does not exist."apt:definitely-not-a-package" = "latest"), re-save the workspace's mise config to re-provision, and confirm the box showspackages: failedwith apt's message, that the tools install still reports its own phase independently, and that the status line carries the "do not guess at the API" sentence.OMP_PACKAGES_SOCKETpointing at a non-existent path, open a session in the same workspace, and confirm the apply is attempted in-process and the failure (sudo unavailable) is reported aspackages: failedrather than hanging or throwing.docker compose up --force-recreate) and open a session in the workspace: the packages are re-applied and reachokagain, confirming the layer-loss path works unattended.Assumptions & contingencies
unverified — confirm first). The plan passesMISE_TRUSTED_CONFIG_PATHS=<scratch root>andMISE_YES=1. If mise still refuses the config as untrusted, copy the scratch root's config files into a root-owned directory under the applier's own control (e.g./run/omp-packages/<workspaceId>/) and run there instead; the files are plain config, and the copy keeps mise's trust check satisfied without widening what is applied.mise bootstrap packages status --jsonfield names (unverified — confirm first). The documented, stable signal isstatus --missing's exit code, which the phase is derived from. Forapplied, inspect the real JSON once in the container and pin the key that carries the declared package name; if the shape is awkward, parse the plain-text table's Manager/Package columns, and if that also fails leaveappliedempty — the phase stays correct either way.apt-get updateitself when/var/lib/apt/listsis empty or a simulated install fails. If a first apply instead fails with "unable to locate package", add--updateto thepackages applyinvocation inpackages/mise-apply.skipped; such a config yieldsdeclared: truewith nothing installed, andstatus --missingdecides the phase. No special handling.[bootstrap.packages]at provisioning time via a root package applier #276