Apply [bootstrap.packages] at provisioning time via a root package applier #274

Closed
opened 2026-11-13 13:13:37 +01:00 by omp-deck · 0 comments
Collaborator

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 .pc files 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-175 spawns exactly ["mise", "install", "--yes"] in the scratch root. mise install applies [tools]; per mise's documentation it only prints a one-time hint about missing [bootstrap.packages] and exits 0. A repo-wide search for bootstrap finds no mise bootstrap invocation anywhere.
  • Because that exit code is 0, runProvision reports phase: "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-279 documents the provisioning contract purely in terms of [tools]; [bootstrap.packages] is not mentioned.
  • mise bootstrap exists in the pinned MISE_VERSION=v2026.11.7 (Dockerfile:96). Its apt manager uses sudo when mise is not running as root; --yes does not supply a sudo password. A session runs as uid 1000 with no sudo in the image (Dockerfile:128-149), so nothing a session can do installs these packages.
  • docker/entrypoint.sh:17 requires the container to start as root and drops both real processes with setpriv (: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 call exec()), 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 in docker/entrypoint.sh:1-9, 47-72 therefore 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 bootstrap run additionally applies services, firewall rules, compose projects, repos, dotfiles, systemd units and the login shell, and runs the config's hooks and bootstrap task — 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 through mise bootstrap --only …, which per mise bootstrap --help runs hooks and the bootstrap task on every invocation.

Approach

Steps are ordered so the tree builds and the existing suite passes after each.

1. Shared apply module: packages/mise-apply

New workspace package @omp-deck/mise-apply (packages/mise-apply/{package.json,tsconfig.json,src/index.ts}), mirroring packages/log's manifest shape ("type": "module", "main": "./src/index.ts", typecheck/test scripts, version 0.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:

export interface BootstrapApplyResult {
	/** False when the config declares neither packages nor files: nothing was run. */
	declared: boolean;
	ok: boolean;
	/** Package keys as declared, e.g. `apt:libwireplumber-0.5-dev`. Empty when none or unparsable. */
	applied: string[];
	/** Set when `ok` is false. Tail of the failing command's stderr, max 2000 chars. */
	error?: string;
}

export async function applyBootstrap(root: string): Promise<BootstrapApplyResult>;

Behaviour of applyBootstrap(root), with root an existing directory holding mise configs:

  1. Run mise bootstrap packages status --json and mise bootstrap files status --json in root. A non-zero exit from either is a failure (ok: false, stderr tail as error).
  2. declared is true when either inspection reports at least one entry. When false, return { declared: false, ok: true, applied: [] } without running anything else.
  3. When files entries exist, run 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.
  4. When package entries exist, run mise bootstrap packages apply --yes.
  5. Verify with mise bootstrap packages status --missing, whose documented contract is exit 1 when any package still differs from the config. Non-zero means ok: false with the command's own output as error, even if step 4 exited 0.
  6. applied is 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.spawn with cwd: root, stdin: "ignore", stdout: "ignore", stderr: "pipe" (the shape already used in provision.ts:142-175), a per-command timeout of 15 * 60_000 ms enforced with setTimeout + proc.kill(), and this environment on top of process.env:

  • MISE_CEILING_PATHS=path.dirname(root) — same reason as provision.ts:149-151: mise's walk up the tree must not pick up stray config from the data dir.
  • MISE_TRUSTED_CONFIG_PATHS=root and MISE_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 on applyBootstrap typed as the spawn function — rather than mocking Bun globally): nothing declared → no apply commands run; files declared → files apply precedes packages apply; files apply failing → packages apply never runs; status --missing exiting 1 after a successful apply → ok: false.

2. Protocol: a separate packages sub-status

In packages/protocol/src/index.ts, beside MiseStatus (:60-70):

/** Outcome of the deck's `[bootstrap.files]` + `[bootstrap.packages]` apply for a workspace. */
export interface MisePackagesStatus {
	phase: MisePhase;
	/** Package keys the config declares, e.g. `apt:libwireplumber-0.5-dev`. */
	applied: string[];
	/** Set only when `phase === "failed"`. */
	error?: string;
}

and add to MiseStatus:

	/** Absent when the workspace's config declares no system packages or files. */
	packages?: MisePackagesStatus;

Absent-when-nothing-declared is deliberate: existing workspaces gain no new UI noise or status-line text.

3. The applier daemon: apps/packages

New workspace app @omp-deck/packages (apps/packages/{package.json,tsconfig.json}), manifest copied from apps/broker/package.json with @omp-deck/mise-apply added to dependencies.

apps/packages/src/index.ts mirrors apps/broker/src/index.ts:27-40:

  • socket path from 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/SIGINT shutdown that stops the server and removes the socket;
  • OMP_DECK_DATA_DIR is required: log an error and process.exit(1) when it is unset, because the scratch root cannot otherwise be derived.

apps/packages/src/server.ts exports createHandler(apply = applyBootstrap):

  • Anything other than POST /apply → 404 {"error":"not found"}.
  • Body is JSON { "workspaceId": string }. Reject with 400 {"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.
  • Scratch root is path.join(process.env.OMP_DECK_DATA_DIR, "mise", "workspaces", workspaceId). When it does not exist, answer 200 {"declared":false,"ok":true,"applied":[]} — the deck has not provisioned that workspace, which is not an error.
  • Otherwise 200 with the BootstrapApplyResult as JSON.

Tests in apps/packages/src/server.test.ts, following apps/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 on apps/server/src/forgejo/broker-client.ts:16-47 (failures returned, never thrown):

export function packagesSocketPath(): string;
export async function applyWorkspacePackages(workspaceId: string, root: string): Promise<BootstrapApplyResult>;

applyWorkspacePackages calls fetch("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:*" to apps/server/package.json). There mise runs as the deck's own user, so its apt manager elevates through sudo; 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-formed BootstrapApplyResult, is a failure with the status text as error — it is not retried through the fallback, since the daemon answered.

5. Wire it into provisioning

In apps/server/src/mise/provision.ts, inside runProvision (:177-214), between the scratch root being filled and runInstall:

  • Keep the existing early return at :201-204 (no sources and nothing pasted → idle): a workspace with no config at all still declares nothing.
  • Set packages: { phase: "running", applied: [] } on the status, then await applyWorkspacePackages(ws.id, root).
  • Build the local value packages: MisePackagesStatus | undefined — undefined when declared is 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.
  • Thread that value through every subsequent setStatus call in runProvision so the packages outcome is not dropped when the tools install finishes.
  • Packages are applied before mise install: a tool that compiles from source commonly needs the declared build dependencies to exist first.
  • A package failure never blocks or fails the tools install; the two phases are reported independently.

The failed branch in provisionWorkspace (:224-232) and setStatus (:66-68) need no signature change — packages rides along in the Omit<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 when status.packages is present:
    • ok: System packages are installed: <applied joined by ", ">. (omit the list when applied is 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.
  • Add apps/server/src/mise/system-context.test.ts covering those four cases plus the absent case (no new sentence).

apps/web/src/components/settings/WorkspacesPanel.tsx: in MiseCell, below the existing phase line (:390-393), render a second text-2xs line when ws.mise.packages is present — packages: <phase> plus the applied keys joined with , — and, when that phase is failed, its error in the same font-mono text-2xs text-red-400 style 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, no bootstrap task, 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:

PACKAGES_SOCKET_DIR=$(dirname "${OMP_PACKAGES_SOCKET:-/run/omp-packages/packages.sock}")
mkdir -p "$PACKAGES_SOCKET_DIR"
chmod 0755 "$PACKAGES_SOCKET_DIR"
env OMP_PACKAGES_SOCKET="${OMP_PACKAGES_SOCKET:-/run/omp-packages/packages.sock}" \
	bun /app/apps/packages/src/index.ts &
PACKAGES=$!

It runs as root — no setpriv — which is the whole point; say so in a comment next to it. Add OMP_PACKAGES_SOCKET to the deck's environment so client and daemon agree. Extend the existing supervision: include $PACKAGES in the trap at :96 and in the shutdown handling at :110-113. Do not add it to the while kill -0 loop 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: add COPY apps/packages/package.json apps/packages/ and COPY packages/mise-apply/package.json packages/mise-apply/ next to the existing manifest copies (:181-185), and COPY apps/packages apps/packages / COPY packages/mise-apply packages/mise-apply next to :191-194.

Adding two workspace members changes the lockfile, and the image builds with bun install --frozen-lockfile (:188): run bun install at the repo root once after the manifests exist and commit bun.lock.

For the published npm package, @omp-deck/mise-apply must be bundled exactly as protocol and log are: add "@omp-deck/mise-apply": "0.6.1" to the root package.json dependencies and to bundledDependencies, and add "mise-apply" to the BUNDLED array in scripts/prepack.mjs:39. Without this the bare-metal fallback path fails to resolve at runtime. apps/packages is not published; it exists only in the image.

Critical files & anchors

File Anchor Why
apps/server/src/mise/provision.ts runProvision, :177-214 The one place the apply is triggered; its two module rules (never driven from a session-controlled directory, never blocking) govern the new call too.
apps/broker/src/index.ts + apps/server/src/forgejo/broker-client.ts :27-40 / :16-47 The exact daemon and client shapes to mirror, including socket lifecycle and failure-returning calls.
docker/entrypoint.sh :74-101 Socket-directory setup, privilege drops, and the supervision loop the applier must join without becoming fatal.
scripts/prepack.mjs BUNDLED, :39 Missing it silently breaks the published package's fallback path.
apps/server/src/mise/system-context.ts GUIDANCE :19-39, miseStatusLine :47-56 Where the agent learns the truth; the current opening paragraph becomes incorrect with this change.

Verification

Prerequisites: repo root, bun install already run.

  1. bun run typecheck and bun test — the whole suite, including the new packages/mise-apply, apps/packages and system-context tests.
  2. New-behavior proof, in the container (this is the check that the feature works, not a smoke test):
    • Build and start the image with a bind-mounted /data as docs/deployment.md describes.
    • Register a workspace whose default-branch mise.toml contains:
      [bootstrap.packages]
      "apt:libwireplumber-0.5-dev" = "latest"
      
    • Open a session in it. Expected, in order: Settings → Workspaces shows packages: running, then packages: ok · apt:libwireplumber-0.5-dev; the session's per-turn context contains System packages are installed: apt:libwireplumber-0.5-dev.; and ls /usr/include/wireplumber-0.5 inside that session lists headers. Before this change the same setup reports ok with no packages line and the directory does not exist.
  3. Failure path: change the declaration to a package that does not exist ("apt:definitely-not-a-package" = "latest"), re-save the workspace's mise config to re-provision, and confirm the box shows packages: failed with 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.
  4. Fallback path: run the deck outside Docker with OMP_PACKAGES_SOCKET pointing 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 as packages: failed rather than hanging or throwing.
  5. Restart the container (docker compose up --force-recreate) and open a session in the workspace: the packages are re-applied and reach ok again, confirming the layer-loss path works unattended.

Assumptions & contingencies

  • mise running as root against a config owned by uid 1000 (unverified — confirm first). The plan passes MISE_TRUSTED_CONFIG_PATHS=<scratch root> and MISE_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 --json field names (unverified — confirm first). The documented, stable signal is status --missing's exit code, which the phase is derived from. For applied, 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 leave applied empty — the phase stays correct either way.
  • Fresh containers have no apt lists. mise documents that it runs apt-get update itself when /var/lib/apt/lists is empty or a simulated install fails. If a first apply instead fails with "unable to locate package", add --update to the packages apply invocation in packages/mise-apply.
  • Images other than Debian/Ubuntu. mise skips entries whose manager is unavailable and reports them as skipped; such a config yields declared: true with nothing installed, and status --missing decides the phase. No special handling.
## 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 `.pc` files 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-175` spawns exactly `["mise", "install", "--yes"]` in the scratch root. `mise install` applies `[tools]`; per mise's documentation it only prints a one-time hint about missing `[bootstrap.packages]` and exits 0. A repo-wide search for `bootstrap` finds no `mise bootstrap` invocation anywhere. - Because that exit code is 0, `runProvision` reports `phase: "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-279` documents the provisioning contract purely in terms of `[tools]`; `[bootstrap.packages]` is not mentioned. - `mise bootstrap` exists in the pinned `MISE_VERSION=v2026.11.7` (`Dockerfile:96`). Its apt manager uses `sudo` when mise is not running as root; `--yes` does not supply a sudo password. A session runs as uid 1000 with no `sudo` in the image (`Dockerfile:128-149`), so nothing a session can do installs these packages. - `docker/entrypoint.sh:17` requires the container to start as root and drops both real processes with `setpriv` (`: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 call `exec()`), 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 in `docker/entrypoint.sh:1-9, 47-72` therefore 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 bootstrap` run additionally applies services, firewall rules, compose projects, repos, dotfiles, systemd units and the login shell, and runs the config's hooks and `bootstrap` task — 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 through `mise bootstrap --only …`, which per `mise bootstrap --help` runs hooks and the `bootstrap` task on every invocation. ### Approach Steps are ordered so the tree builds and the existing suite passes after each. #### 1. Shared apply module: `packages/mise-apply` New workspace package `@omp-deck/mise-apply` (`packages/mise-apply/{package.json,tsconfig.json,src/index.ts}`), mirroring `packages/log`'s manifest shape (`"type": "module"`, `"main": "./src/index.ts"`, `typecheck`/`test` scripts, version `0.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: ```ts export interface BootstrapApplyResult { /** False when the config declares neither packages nor files: nothing was run. */ declared: boolean; ok: boolean; /** Package keys as declared, e.g. `apt:libwireplumber-0.5-dev`. Empty when none or unparsable. */ applied: string[]; /** Set when `ok` is false. Tail of the failing command's stderr, max 2000 chars. */ error?: string; } export async function applyBootstrap(root: string): Promise<BootstrapApplyResult>; ``` Behaviour of `applyBootstrap(root)`, with `root` an existing directory holding mise configs: 1. Run `mise bootstrap packages status --json` and `mise bootstrap files status --json` in `root`. A non-zero exit from either is a failure (`ok: false`, stderr tail as `error`). 2. `declared` is true when either inspection reports at least one entry. When false, return `{ declared: false, ok: true, applied: [] }` without running anything else. 3. When files entries exist, run `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. 4. When package entries exist, run `mise bootstrap packages apply --yes`. 5. Verify with `mise bootstrap packages status --missing`, whose documented contract is exit 1 when any package still differs from the config. Non-zero means `ok: false` with the command's own output as `error`, even if step 4 exited 0. 6. `applied` is 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.spawn` with `cwd: root`, `stdin: "ignore"`, `stdout: "ignore"`, `stderr: "pipe"` (the shape already used in `provision.ts:142-175`), a per-command timeout of `15 * 60_000` ms enforced with `setTimeout` + `proc.kill()`, and this environment on top of `process.env`: - `MISE_CEILING_PATHS=path.dirname(root)` — same reason as `provision.ts:149-151`: mise's walk up the tree must not pick up stray config from the data dir. - `MISE_TRUSTED_CONFIG_PATHS=root` and `MISE_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 on `applyBootstrap` typed as the spawn function — rather than mocking Bun globally): nothing declared → no apply commands run; files declared → `files apply` precedes `packages apply`; files apply failing → packages apply never runs; `status --missing` exiting 1 after a successful apply → `ok: false`. #### 2. Protocol: a separate packages sub-status In `packages/protocol/src/index.ts`, beside `MiseStatus` (`:60-70`): ```ts /** Outcome of the deck's `[bootstrap.files]` + `[bootstrap.packages]` apply for a workspace. */ export interface MisePackagesStatus { phase: MisePhase; /** Package keys the config declares, e.g. `apt:libwireplumber-0.5-dev`. */ applied: string[]; /** Set only when `phase === "failed"`. */ error?: string; } ``` and add to `MiseStatus`: ```ts /** Absent when the workspace's config declares no system packages or files. */ packages?: MisePackagesStatus; ``` Absent-when-nothing-declared is deliberate: existing workspaces gain no new UI noise or status-line text. #### 3. The applier daemon: `apps/packages` New workspace app `@omp-deck/packages` (`apps/packages/{package.json,tsconfig.json}`), manifest copied from `apps/broker/package.json` with `@omp-deck/mise-apply` added to `dependencies`. `apps/packages/src/index.ts` mirrors `apps/broker/src/index.ts:27-40`: - socket path from `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`/`SIGINT` shutdown that stops the server and removes the socket; - `OMP_DECK_DATA_DIR` is required: log an error and `process.exit(1)` when it is unset, because the scratch root cannot otherwise be derived. `apps/packages/src/server.ts` exports `createHandler(apply = applyBootstrap)`: - Anything other than `POST /apply` → `404 {"error":"not found"}`. - Body is JSON `{ "workspaceId": string }`. Reject with `400 {"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. - Scratch root is `path.join(process.env.OMP_DECK_DATA_DIR, "mise", "workspaces", workspaceId)`. When it does not exist, answer `200 {"declared":false,"ok":true,"applied":[]}` — the deck has not provisioned that workspace, which is not an error. - Otherwise `200` with the `BootstrapApplyResult` as JSON. Tests in `apps/packages/src/server.test.ts`, following `apps/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 on `apps/server/src/forgejo/broker-client.ts:16-47` (failures returned, never thrown): ```ts export function packagesSocketPath(): string; export async function applyWorkspacePackages(workspaceId: string, root: string): Promise<BootstrapApplyResult>; ``` `applyWorkspacePackages` calls `fetch("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:*"` to `apps/server/package.json`). There mise runs as the deck's own user, so its apt manager elevates through `sudo`; 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-formed `BootstrapApplyResult`, is a failure with the status text as `error` — it is not retried through the fallback, since the daemon answered. #### 5. Wire it into provisioning In `apps/server/src/mise/provision.ts`, inside `runProvision` (`:177-214`), between the scratch root being filled and `runInstall`: - Keep the existing early return at `:201-204` (no sources and nothing pasted → `idle`): a workspace with no config at all still declares nothing. - Set `packages: { phase: "running", applied: [] }` on the status, then `await applyWorkspacePackages(ws.id, root)`. - Build the local value `packages: MisePackagesStatus | undefined` — `undefined` when `declared` is 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`. - Thread that value through every subsequent `setStatus` call in `runProvision` so the packages outcome is not dropped when the tools install finishes. - Packages are applied **before** `mise install`: a tool that compiles from source commonly needs the declared build dependencies to exist first. - A package failure never blocks or fails the tools install; the two phases are reported independently. The `failed` branch in `provisionWorkspace` (`:224-232`) and `setStatus` (`:66-68`) need no signature change — `packages` rides along in the `Omit<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 when `status.packages` is present: - `ok`: `System packages are installed: <applied joined by ", ">.` (omit the list when `applied` is 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. - Add `apps/server/src/mise/system-context.test.ts` covering those four cases plus the absent case (no new sentence). `apps/web/src/components/settings/WorkspacesPanel.tsx`: in `MiseCell`, below the existing phase line (`:390-393`), render a second `text-2xs` line when `ws.mise.packages` is present — `packages: <phase>` plus the applied keys joined with `, ` — and, when that phase is `failed`, its error in the same `font-mono text-2xs text-red-400` style 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, no `bootstrap` task, 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`: ```sh PACKAGES_SOCKET_DIR=$(dirname "${OMP_PACKAGES_SOCKET:-/run/omp-packages/packages.sock}") mkdir -p "$PACKAGES_SOCKET_DIR" chmod 0755 "$PACKAGES_SOCKET_DIR" env OMP_PACKAGES_SOCKET="${OMP_PACKAGES_SOCKET:-/run/omp-packages/packages.sock}" \ bun /app/apps/packages/src/index.ts & PACKAGES=$! ``` It runs as root — no `setpriv` — which is the whole point; say so in a comment next to it. Add `OMP_PACKAGES_SOCKET` to the deck's environment so client and daemon agree. Extend the existing supervision: include `$PACKAGES` in the `trap` at `:96` and in the shutdown handling at `:110-113`. Do **not** add it to the `while kill -0` loop 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`: add `COPY apps/packages/package.json apps/packages/` and `COPY packages/mise-apply/package.json packages/mise-apply/` next to the existing manifest copies (`:181-185`), and `COPY apps/packages apps/packages` / `COPY packages/mise-apply packages/mise-apply` next to `:191-194`. Adding two workspace members changes the lockfile, and the image builds with `bun install --frozen-lockfile` (`:188`): run `bun install` at the repo root once after the manifests exist and commit `bun.lock`. For the published npm package, `@omp-deck/mise-apply` must be bundled exactly as `protocol` and `log` are: add `"@omp-deck/mise-apply": "0.6.1"` to the root `package.json` `dependencies` and to `bundledDependencies`, and add `"mise-apply"` to the `BUNDLED` array in `scripts/prepack.mjs:39`. Without this the bare-metal fallback path fails to resolve at runtime. `apps/packages` is not published; it exists only in the image. ### Critical files & anchors | File | Anchor | Why | | --- | --- | --- | | `apps/server/src/mise/provision.ts` | `runProvision`, `:177-214` | The one place the apply is triggered; its two module rules (never driven from a session-controlled directory, never blocking) govern the new call too. | | `apps/broker/src/index.ts` + `apps/server/src/forgejo/broker-client.ts` | `:27-40` / `:16-47` | The exact daemon and client shapes to mirror, including socket lifecycle and failure-returning calls. | | `docker/entrypoint.sh` | `:74-101` | Socket-directory setup, privilege drops, and the supervision loop the applier must join without becoming fatal. | | `scripts/prepack.mjs` | `BUNDLED`, `:39` | Missing it silently breaks the published package's fallback path. | | `apps/server/src/mise/system-context.ts` | `GUIDANCE` `:19-39`, `miseStatusLine` `:47-56` | Where the agent learns the truth; the current opening paragraph becomes incorrect with this change. | ### Verification Prerequisites: repo root, `bun install` already run. 1. `bun run typecheck` and `bun test` — the whole suite, including the new `packages/mise-apply`, `apps/packages` and `system-context` tests. 2. New-behavior proof, in the container (this is the check that the feature works, not a smoke test): - Build and start the image with a bind-mounted `/data` as `docs/deployment.md` describes. - Register a workspace whose default-branch `mise.toml` contains: ```toml [bootstrap.packages] "apt:libwireplumber-0.5-dev" = "latest" ``` - Open a session in it. Expected, in order: Settings → Workspaces shows `packages: running`, then `packages: ok · apt:libwireplumber-0.5-dev`; the session's per-turn context contains `System packages are installed: apt:libwireplumber-0.5-dev.`; and `ls /usr/include/wireplumber-0.5` inside that session lists headers. Before this change the same setup reports `ok` with no packages line and the directory does not exist. 3. Failure path: change the declaration to a package that does not exist (`"apt:definitely-not-a-package" = "latest"`), re-save the workspace's mise config to re-provision, and confirm the box shows `packages: failed` with 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. 4. Fallback path: run the deck outside Docker with `OMP_PACKAGES_SOCKET` pointing 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 as `packages: failed` rather than hanging or throwing. 5. Restart the container (`docker compose up --force-recreate`) and open a session in the workspace: the packages are re-applied and reach `ok` again, confirming the layer-loss path works unattended. ### Assumptions & contingencies - **mise running as root against a config owned by uid 1000** (`unverified — confirm first`). The plan passes `MISE_TRUSTED_CONFIG_PATHS=<scratch root>` and `MISE_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 --json` field names** (`unverified — confirm first`). The documented, stable signal is `status --missing`'s exit code, which the phase is derived from. For `applied`, 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 leave `applied` empty — the phase stays correct either way. - **Fresh containers have no apt lists.** mise documents that it runs `apt-get update` itself when `/var/lib/apt/lists` is empty or a simulated install fails. If a first apply instead fails with "unable to locate package", add `--update` to the `packages apply` invocation in `packages/mise-apply`. - **Images other than Debian/Ubuntu.** mise skips entries whose manager is unavailable and reports them as `skipped`; such a config yields `declared: true` with nothing installed, and `status --missing` decides the phase. No special handling.
lucas closed this issue 2026-11-13 15:19:42 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#274
No description provided.