How to provide additional dev tools? #231

Closed
opened 2026-11-08 02:09:44 +01:00 by lucas · 4 comments
Owner

Problem

When starting to use omp-deck with more and more projects, the amount of different dev tools required keeps increasing. Installing all of them in the Dockerfile is annoying for multiple reasons:

  • duplicated from the host, which is a dev machine and already runs many of those
  • increases the image size by a lot
  • cannot be controlled by the project individually, instead requires a change in omp-deck

The agreed answer is mise: one small static binary in the image, with each project declaring its own tools in a file it owns. The deck integrates it so the agent mostly does not have to think about it — tools resolve per directory, installs persist across container recreation, and the deck provisions a workspace's toolchain without being asked.

Two constraints shape the whole design, both verified in this repository:

  • Sessions share one process environment. InProcessAgentBridge.createSession (apps/server/src/bridge/in-process.ts:290) never passes env to createAgentSession, and the only PATH in the repo is Dockerfile:68,151. There is no per-session environment, so mise activate (one env per shell) is not available as the integration path. mise shims are, because a shim resolves the tool version from the invocation's own directory: a single global PATH entry gives two concurrent sessions in two workspaces two different toolchains.
  • Trigger sessions run in a clone of a PR branch. forgejo/checkout.ts puts them in <dataDir>/checkouts/<owner>/<repo>/<kind>-<n>, detached at refs/pull/<n>/head. mise's own docs state that mise install, mise exec and mise run automatically trust the config they load, so naively running mise install in a session's cwd would execute whatever a pull-request author pushed, and silently mark it trusted. Installs must therefore never be driven by a session's cwd.

Scope of this issue: the image/persistence layer, deck-driven provisioning, a per-workspace config pasted in Settings, and the session context that tells the agent how tools are provided. The "detect a missing tool and open a PR adjusting the mise config" loop is deliberately not part of it. Nor is shrinking the image (chromium, build-essential, /opt/pytools, the npm language-server globals); every tool baked in today stays baked in.

Plan

1. Put mise in the image, with shims first on PATH

docker/entrypoint.sh:93 runs the deck as env HOME=/data bun src/index.ts, and /data is the bind-mounted volume. Every mise directory defaults to a location under $HOME — data and shims in ~/.local/share/mise, global config in ~/.config/mise, trust decisions and tracked config paths in ~/.local/state/mise, cache in ~/.cache/mise — so no MISE_*_DIR override is needed: everything mise installs or records already persists on the volume. Do not add any.

In Dockerfile, add a build stage after the just stage (line 84), mirroring that stage exactly — separate so the download caches independently of runtime changes:

# ─── Stage: mise ───────────────────────────────────────────────────────────
#
# Per-project dev tools. Pinned: an unpinned mise.run install resolves a
# release by age, which would make image builds non-reproducible.
FROM oven/bun:1.3.14 AS mise
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean \
    && apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl
ENV MISE_VERSION=v2026.11.7 \
    MISE_INSTALL_PATH=/usr/local/bin/mise
RUN curl --proto '=https' --tlsv1.2 -sSf https://mise.run | sh

MISE_VERSION and MISE_INSTALL_PATH are both honoured by that installer (verified against the script itself). Bump the pin through the existing .renovaterc machinery if it picks it up; otherwise it is a manual bump like the oven/bun tag.

In the runtime stage, next to the other stage copies at Dockerfile:143-146:

COPY --link --from=mise /usr/local/bin/mise /usr/local/bin/mise

Replace the ENV block at Dockerfile:149-151 so the shim directory leads PATH:

# The shim directory is $HOME/.local/share/mise/shims with HOME=/data, which
# the entrypoint sets for the deck. It leads PATH so a project's declared
# version wins over the image's baked-in toolchains. A missing directory is
# simply ignored, so this is harmless before the first install.
ENV RUSTUP_HOME=/usr/local/rustup \
    CARGO_HOME=/usr/local/cargo \
    MISE_NOT_FOUND_AUTO_INSTALL=0 \
    PATH=/data/.local/share/mise/shims:/usr/local/cargo/bin:$PATH:/opt/pytools/bin

MISE_NOT_FOUND_AUTO_INSTALL=0 is load-bearing, not tidiness: with mise's default, a shim running inside a PR checkout resolves that checkout's mise.toml and installs whatever version it names, which is exactly the PR-driven install this design rules out. exec_auto_install stays at its default true, so the agent can still deliberately run mise exec <tool>@<ver> -- … and have it resolve. Set this as an image ENV rather than a managed global config file: it is a security property, and an ENV cannot be overridden by anything the deck or a project writes under /data.

Leave not_found_system_fallback at its default true. Turning it off would make an unresolvable shim fail — but a shim exists for every executable of every tool mise has ever installed, so once any project installs node, a hard-failing node shim would break every other project that relies on the image's Debian nodejs. The cost of keeping the fallback is that a declared-but-uninstalled tool can silently run a system binary of a different version; section 5's context block tells the agent to check with mise which rather than command -v.

No change to docker/entrypoint.sh. mise creates its own directories under /data, which the deck UID already owns.

2. Store a per-workspace mise config

A workspace may carry a config pasted in Settings, used on top of (or instead of) whatever the repo declares. Follow the victorialogs_enabled chain end to end; the next free migration number is 026 (025-forgejo-trigger-quota-notice.sql is the latest).

apps/server/src/db/migrations/026-workspace-mise-config.sql:

-- A mise config pasted for this workspace in Settings → Workspaces. `mode`
-- decides how it meets the repo's own config: 'override' merges it over the
-- repo's (mise's native per-tool precedence via mise.local.toml), 'replace'
-- makes mise read only this config in the workspace and its checkouts.
ALTER TABLE workspaces ADD COLUMN mise_config TEXT;
ALTER TABLE workspaces ADD COLUMN mise_config_mode TEXT NOT NULL DEFAULT 'override';

apps/server/src/db/workspaces.ts: add mise_config: string | null and mise_config_mode: string to WorkspaceRow (line 22), both names to WORKSPACE_COLUMNS (line 44), and to rowToWorkspace (line 32) miseConfig: r.mise_config ?? "" and miseConfigMode: r.mise_config_mode === "replace" ? "replace" : "override". Add next to setWorkspaceVictoriaLogsEnabled:

export function setWorkspaceMiseConfig(
	workspaceId: string,
	config: string,
	mode: MiseConfigMode,
): Workspace | undefined

storing config.trim() === "" ? null : config and mode, returning getWorkspace(workspaceId), and returning undefined when the workspace does not exist — identical in shape to the VictoriaLogs setter.

packages/protocol/src/index.ts: add export type MiseConfigMode = "override" | "replace"; and extend

  • Workspace (line 54) with miseConfig: string; (empty string = none) and miseConfigMode: MiseConfigMode;
  • WorkspaceDetails (line 96) with the same two fields plus mise: MiseStatus; (section 4)
  • UpdateWorkspaceRequest (line 124) with miseConfig?: string; and miseConfigMode?: MiseConfigMode;

apps/server/src/routes-workspaces.ts, in PATCH /workspaces/:id (line 104): extend the required-field guard at line 112 to mention miseConfig, validate miseConfig is a string and miseConfigMode is "override" | "replace" (400 otherwise), and add a branch after the VictoriaLogs one calling setWorkspaceMiseConfig(id, body.miseConfig ?? ws.miseConfig, body.miseConfigMode ?? ws.miseConfigMode). Either field alone is a valid update. After a successful write, call void provisionWorkspace(ws) (section 3) so the change takes effect without reopening a session, then notifyWorkspacesChanged() as the existing branches do.

apps/server/src/workspace-details.ts: add miseConfig, miseConfigMode and mise: miseStatus(ws.id) beside the existing victorialogsEnabled field at line 78.

apps/web/src/lib/api.ts, beside setWorkspaceVictoriaLogs (line 63):

setWorkspaceMiseConfig(id: string, miseConfig: string, miseConfigMode: MiseConfigMode): Promise<Workspace>

PATCHing { miseConfig, miseConfigMode }.

apps/web/src/components/settings/WorkspacesPanel.tsx: add a <dt>mise</dt> / <dd> pair to the <dl> after the VictoriaLogs row (ends line 322). Unlike the two checkboxes it needs an explicit commit, so: a <textarea> bound to local state seeded from ws.miseConfig, a <select> with override / replace, a Save button disabled while busy or unchanged, and the line Applies immediately; the workspace is re-provisioned in the background. Render ws.mise underneath as phase plus, when phase === "failed", the recorded error. Label the modes in the UI as override — merged over the repo's own config and replace — the repo's own config is ignored.

3. Provision a workspace's toolchain

New directory apps/server/src/mise/. Nothing here may ever take instructions from a session's cwd.

3a. apps/server/src/mise/materialize.ts

Writes the deck-managed files that make mise resolve the right versions in a directory. Exports:

export const MANAGED_HEADER = "# Managed by omp-deck — edited in Settings → Workspaces. Local edits are overwritten.";
export async function materializeMiseConfig(dir: string, cfg: { text: string; mode: MiseConfigMode } | null): Promise<void>;

Behaviour:

  • cfg === null or cfg.text.trim() === "": delete <dir>/mise.local.toml and <dir>/.miserc.toml, but only if the file exists and its first line is MANAGED_HEADER. Return.

  • Otherwise write <dir>/mise.local.toml as MANAGED_HEADER + "\n" + cfg.text. mise reads mise.local.toml ahead of mise.toml in the same directory and documents it as the file for personal, uncommitted overrides, which is exactly this. [tools] merges per tool, so a pasted node entry replaces the repo's node and leaves the repo's other tools in force.

  • cfg.mode === "replace": additionally write <dir>/.miserc.toml:

    # Managed by omp-deck — edited in Settings → Workspaces. Local edits are overwritten.
    override_config_filenames = ["mise.local.toml"]
    override_tool_versions_filenames = ["none"]
    

    override_config_filenames makes mise load only files with those names in each directory, and override_tool_versions_filenames = ["none"] stops .tool-versions being read; both are settable in a project .miserc.toml (rc = true in mise's settings schema), and .miserc.toml can only select which config files load, so it carries no code and needs no trust. cfg.mode === "override": delete a managed .miserc.toml if present.

  • Before overwriting either file, if it exists and does not start with MANAGED_HEADER, throw MiseConfigConflict naming the path, and write nothing. A user's own mise.local.toml is never clobbered.

  • When <dir>/.git exists, ensure .git/info/exclude contains a block

    # omp-deck managed mise config
    /mise.local.toml
    /.miserc.toml
    

    appended once, keyed on that comment line, so the managed files never show up as untracked. Create .git/info/ if missing.

3b. apps/server/src/mise/provision.ts

export type MisePhase = "idle" | "running" | "ok" | "failed";
export interface MiseStatus {
	phase: MisePhase;
	/** Config files the install was driven from, workspace-relative. */
	sources: string[];
	/** Set only when phase === "failed". */
	error?: string;
	updatedAt: string;
}
export function miseStatus(workspaceId: string): MiseStatus;
export async function provisionWorkspace(ws: Workspace): Promise<void>;
export function kickoffMiseProvisioning(workspaceId: string | null): void;

State lives in two module-level maps keyed by workspace id: the MiseStatus record, and the in-flight Promise<void> so concurrent sessions in one workspace never run two installs (return the existing promise instead of starting a second). Both are in-memory; after a deck restart a workspace reports idle until its next session, which is correct — provisioning is idempotent and cheap once warm.

provisionWorkspace(ws) steps, in order:

  1. Set phase running.

  2. await materializeMiseConfig(ws.path, ws.miseConfig ? { text: ws.miseConfig, mode: ws.miseConfigMode } : null). Do this first, so version resolution in the workspace is correct even while the install is still running. A MiseConfigConflict here sets phase failed with the thrown message and stops.

  3. Build a scratch config root at <getDataDir()>/mise/workspaces/<ws.id>: rm -rf then mkdir -p. Installs are driven from this directory and never from ws.path or a checkout, because the working tree may sit on any branch and a checkout holds a pull request's code.

  4. Unless the mode is replace, copy the default branch's config into the scratch root. Resolve a ref by trying, in order, origin/HEAD and origin/<default branch>; use runGit from apps/server/src/bridge/git-tools/run.ts with readOnly: true — it spawns git as an argv array with no shell, which is what every other git caller in the server uses. For each candidate relative path

    mise.toml  .mise.toml  mise/config.toml  .mise/config.toml
    .config/mise.toml  .config/mise/config.toml  .tool-versions  mise.lock
    

    run git show <ref>:<rel> and, on exit 0, write the output into the scratch root at the same relative path. Copy every one that exists rather than picking a winner: that keeps mise's own precedence rules intact instead of inventing one. If neither ref resolves — a workspace with no remote, or not a git work tree at all — fall back to copying the same paths from ws.path on disk; there is no pull-request risk in a repo that has no remote. Record the copied paths as sources.

  5. If the workspace has a pasted config, write it into the scratch root as mise.local.toml (header included, same as the materialized one). In replace mode step 4 copied nothing, so this is the only config present and no scratch .miserc.toml is needed.

  6. If the scratch root is empty, set phase idle with empty sources and return — nothing to provision.

  7. Run mise install --yes with cwd = the scratch root, MISE_CEILING_PATHS=<getDataDir()>/mise/workspaces in the environment so the directory walk cannot pick up stray config from /data, and a 30-minute timeout. Spawn it as an argv array via Bun.spawn, matching how git-tools/run.ts spawns git; do not go through a shell. Running mise install here auto-trusts the scratch config, which is intended and safe: its content came from the repository's default branch or from the user's own paste.

  8. Exit 0 → phase ok. Non-zero, timeout, or a spawn failure → phase failed, error set to the last 2000 characters of stderr, and one log.warn naming the workspace. A failed provision never throws to the caller and never blocks a session.

kickoffMiseProvisioning(workspaceId) looks the workspace up and calls void provisionWorkspace(ws) without awaiting. A null id is a no-op.

3c. Wire it into session creation

In apps/server/src/bridge/in-process.ts, integrationWorkspaceId is already resolved at line 312 — the trigger path through opts.placement.workspaceId, the interactive path through getWorkspaceByPath(opts.cwd). Immediately after it, add kickoffMiseProvisioning(integrationWorkspaceId). Do the same at the corresponding line in resumeSession (around line 464). Not awaited: POST /sessions (apps/server/src/routes.ts:101) awaits createSession, and the web client awaits that HTTP call (apps/web/src/lib/store.ts:382), so a cold Rust or Python toolchain would otherwise freeze "New session" for minutes. createSession is also reached from forgejo/dispatcher.ts:2049, :2156, :2936 and forgejo/plan-issue.ts:199; all of them resolve their workspace through the same variable, so this one call site covers every path.

3d. Apply the workspace config inside trigger checkouts

A checkout lives outside workspace.path, so nothing materialized in the workspace reaches it. In apps/server/src/forgejo/checkout.ts, after the local-branch cleanup at the end of createCheckout and at the same point in restoreCheckout, resolve the workspace with getWorkspaceByPath(opts.workspacePath) — no signature change is needed — and call materializeMiseConfig(dest, …) with its pasted config, or with null when there is none. createEmptyCheckout has no workspace and is left alone. In replace mode this also makes the pull request's own mise.toml inert inside the clone, which is the stronger behaviour, not a weaker one. Swallow and log a MiseConfigConflict here rather than failing the checkout: a clone is disposable and a broken toolchain must not block a trigger session from opening.

4. Tell the agent where tools come from

The guidance must not go into DEFAULT_PRELUDE: getEffectivePrelude() (apps/server/src/orientation-store.ts) returns <dataDir>/prelude.md whenever it exists, so anyone who has customised their prelude would never see an edit to the default. Add it as its own entry in the system-prompt array instead, the way forgejoContext already is.

New apps/server/src/mise/system-context.ts exporting miseSessionContext(workspaceId: string | null): string | null, returning null when there is no workspace. Otherwise the static block below, followed by a status line built from miseStatus(workspaceId) — Workspace tool provisioning: <phase>., plus Driven from: <sources joined>. when non-empty, plus the recorded error when failed.

## Dev tools (mise)

Dev tools in this container come from mise, not from apt. The image is
deliberately small, the session has no root, and anything apt installed would
be lost when the container is recreated. `$HOME` is `/data`, a bind mount, so
everything mise installs persists.

- mise shims lead `PATH`. A tool a project declares resolves to that project's
  version automatically, per directory, with no activation step.
- Shims never auto-install here. A declared-but-uninstalled tool falls through
  to a same-named system binary, which may be a different version. Check with
  `mise which <tool>`, never `command -v <tool>` — `command -v` succeeds for
  any tool that ever had a shim, whether or not it resolves here.
- To use a tool right now: `mise exec <tool>@<version> -- <command>`, which
  installs it on demand. To keep it for later: `mise install <tool>@<version>`,
  and `mise use --global <tool>@<version>` to make it resolve everywhere.
- `[env]` values from a project's config reach shimmed programs, not the
  ambient shell. A build that needs them must run through `mise exec --` or
  `mise run`.
- A tool this project should always have belongs in its own mise config, not in
  a global install.

Wire it into both systemPrompt arrays in in-process.ts — line 334 and the resume copy near line 480 — directly after the forgejoContext entry:

...(miseContext ? [miseContext] : []),

Call miseSessionContext(integrationWorkspaceId) inside the closure, not before it, so the status refreshes as provisioning progresses: the resolver is invoked per turn preparation (node_modules/@oh-my-pi/pi-coding-agent/src/sdk.ts:4411), which is also why the existing ideaBridgeRef.current?.isEnabled() check in that array works despite the ref being null at create time. Unverified — confirm first: if the closure turns out to run once at creation, the status cannot update that way; in that case keep the static block in the closure and move the status line into a small extension registered alongside gate in the extensions: array (line 343) that handles before_agent_start and returns { systemPrompt: [...event.systemPrompt, statusLine] }. Do not add a push-to-live-session mechanism for this.

5. Verification

Everything below runs against a container built from this branch. Prerequisite for all of it: docker build -t omp-deck . and a run with -v <host-dir>:/data as the Dockerfile header describes.

  1. mise is present and persists. In a session's bash: mise --version prints the pinned 2026.11.7, and mise doctor reports its data directory as /data/.local/share/mise and its shims directory as /data/.local/share/mise/shims. echo $PATH shows that shims path first. Recreate the container (docker rm -f + run again) and confirm mise ls --global still lists anything installed before the recreate.
  2. Auto-install is off for shims but on for exec. In a directory with a mise.toml declaring a tool that is not installed, running the tool directly must not install it; mise exec <tool>@<version> -- <tool> --version must install and print a version.
  3. A repo's own config provisions. Register a workspace whose default branch commits mise.toml with, say, [tools]\njq = "1.7.1". Open a session in it. Within the install's runtime, mise which jq inside the workspace resolves to a path under /data/.local/share/mise/installs/jq/1.7.1, and jq --version prints jq-1.7.1 rather than the image's Debian jq. GET /api/workspaces/details shows that workspace's mise.phase as ok with mise.toml in sources.
  4. A pull request cannot drive an install. On a branch of that workspace's repo, push a commit changing mise.toml to a version that is not installed, open a pull request, and let a trigger session start. In its checkout, the tool must not be installed: ls /data/.local/share/mise/installs/jq still shows only the default branch's version, and the deck log contains no mise install for the checkout path. This is the security property the design exists for; check it explicitly.
  5. Pasted config, override mode. In Settings → Workspaces, paste [tools]\njq = "1.7" for a workspace whose repo declares jq and something else, leave the mode on override, Save. <workspace>/mise.local.toml appears carrying the managed header, git status in the workspace reports a clean tree (the file is excluded), mise which jq resolves to the pasted version, and the repo's other tool still resolves.
  6. Pasted config, replace mode. Switch the same workspace to replace and Save. <workspace>/.miserc.toml appears with override_config_filenames = ["mise.local.toml"], mise config ls in the workspace lists only mise.local.toml, and the tool the repo declared but the paste does not no longer resolves. Clear the textarea and Save: both managed files are gone and the repo's config resolves again.
  7. Checkouts inherit the paste. With a paste in place, trigger a new pull-request session and confirm mise.local.toml exists in <dataDir>/checkouts/<owner>/<repo>/pr-<n>/ with the managed header.
  8. A user's own file is never clobbered. Hand-write a mise.local.toml without the managed header into a workspace, then Save a paste for it. The file must be untouched, and that workspace's mise.phase must be failed with an error naming the path.
  9. Failure never blocks. Paste a config naming a tool that does not exist ([tools]\nnot-a-real-tool = "1"), Save, and open a session. Session creation must succeed promptly, the session's system context must carry Workspace tool provisioning: failed with the error text, and the deck log must carry the warning.
  10. Existing behaviour is intact. bun test in the repo root passes, including apps/server/src/forgejo/dispatcher.test.ts, whose bridge fake exercises the createSession path the provisioning kickoff was added to.

Assumptions & contingencies

  • mise's .miserc.toml honours override_config_filenames from a project directory. Both settings are marked rc = true in mise's settings schema, which is what permits them in a .miserc.toml. If step 6 of Verification shows the repo's mise.toml still loading in replace mode, fall back to ignored_config_paths in the same .miserc.toml, listing the repo config paths the deck found in step 4 of provisioning; it is rc = true as well and its relative paths resolve from the directory holding the file.
  • git show origin/HEAD:… resolves in a workspace. This depends on refs/remotes/origin/HEAD existing, which a plain git clone creates but a repo set up by hand may lack. The ordered fallback to origin/<default branch> and then to the on-disk files covers it, so no step stalls; if origin/HEAD turns out to be missing often in practice, read the default branch name from the existing Forgejo repo metadata rather than adding a git remote set-head call — the deck must not mutate the user's repository.
  • The deck never fetches in a workspace, so the default-branch config it reads is as fresh as the user's last fetch. That is intentional: fetching would be a write to the user's repository. If a provision looks stale, the fix is a git fetch the user runs.
## Problem When starting to use omp-deck with more and more projects, the amount of different dev tools required keeps increasing. Installing all of them in the Dockerfile is annoying for multiple reasons: - duplicated from the host, which is a dev machine and already runs many of those - increases the image size by a lot - cannot be controlled by the project individually, instead requires a change in omp-deck The agreed answer is [mise](https://mise.jdx.dev): one small static binary in the image, with each project declaring its own tools in a file it owns. The deck integrates it so the agent mostly does not have to think about it — tools resolve per directory, installs persist across container recreation, and the deck provisions a workspace's toolchain without being asked. Two constraints shape the whole design, both verified in this repository: - **Sessions share one process environment.** `InProcessAgentBridge.createSession` (`apps/server/src/bridge/in-process.ts:290`) never passes `env` to `createAgentSession`, and the only `PATH` in the repo is `Dockerfile:68,151`. There is no per-session environment, so `mise activate` (one env per shell) is not available as the integration path. mise **shims** are, because a shim resolves the tool version from the invocation's own directory: a single global `PATH` entry gives two concurrent sessions in two workspaces two different toolchains. - **Trigger sessions run in a clone of a PR branch.** `forgejo/checkout.ts` puts them in `<dataDir>/checkouts/<owner>/<repo>/<kind>-<n>`, detached at `refs/pull/<n>/head`. mise's own docs state that `mise install`, `mise exec` and `mise run` *automatically trust the config they load*, so naively running `mise install` in a session's cwd would execute whatever a pull-request author pushed, and silently mark it trusted. Installs must therefore never be driven by a session's cwd. Scope of this issue: the image/persistence layer, deck-driven provisioning, a per-workspace config pasted in Settings, and the session context that tells the agent how tools are provided. The "detect a missing tool and open a PR adjusting the mise config" loop is deliberately **not** part of it. Nor is shrinking the image (`chromium`, `build-essential`, `/opt/pytools`, the npm language-server globals); every tool baked in today stays baked in. ## Plan ### 1. Put `mise` in the image, with shims first on `PATH` `docker/entrypoint.sh:93` runs the deck as `env HOME=/data bun src/index.ts`, and `/data` is the bind-mounted volume. Every mise directory defaults to a location under `$HOME` — data and shims in `~/.local/share/mise`, global config in `~/.config/mise`, trust decisions and tracked config paths in `~/.local/state/mise`, cache in `~/.cache/mise` — so **no `MISE_*_DIR` override is needed**: everything mise installs or records already persists on the volume. Do not add any. In `Dockerfile`, add a build stage after the `just` stage (line 84), mirroring that stage exactly — separate so the download caches independently of runtime changes: ```dockerfile # ─── Stage: mise ─────────────────────────────────────────────────────────── # # Per-project dev tools. Pinned: an unpinned mise.run install resolves a # release by age, which would make image builds non-reproducible. FROM oven/bun:1.3.14 AS mise RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ rm -f /etc/apt/apt.conf.d/docker-clean \ && apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl ENV MISE_VERSION=v2026.11.7 \ MISE_INSTALL_PATH=/usr/local/bin/mise RUN curl --proto '=https' --tlsv1.2 -sSf https://mise.run | sh ``` `MISE_VERSION` and `MISE_INSTALL_PATH` are both honoured by that installer (verified against the script itself). Bump the pin through the existing `.renovaterc` machinery if it picks it up; otherwise it is a manual bump like the `oven/bun` tag. In the `runtime` stage, next to the other stage copies at `Dockerfile:143-146`: ```dockerfile COPY --link --from=mise /usr/local/bin/mise /usr/local/bin/mise ``` Replace the `ENV` block at `Dockerfile:149-151` so the shim directory leads `PATH`: ```dockerfile # The shim directory is $HOME/.local/share/mise/shims with HOME=/data, which # the entrypoint sets for the deck. It leads PATH so a project's declared # version wins over the image's baked-in toolchains. A missing directory is # simply ignored, so this is harmless before the first install. ENV RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo \ MISE_NOT_FOUND_AUTO_INSTALL=0 \ PATH=/data/.local/share/mise/shims:/usr/local/cargo/bin:$PATH:/opt/pytools/bin ``` `MISE_NOT_FOUND_AUTO_INSTALL=0` is load-bearing, not tidiness: with mise's default, a shim running inside a PR checkout resolves that checkout's `mise.toml` and installs whatever version it names, which is exactly the PR-driven install this design rules out. `exec_auto_install` stays at its default `true`, so the agent can still deliberately run `mise exec <tool>@<ver> -- …` and have it resolve. Set this as an image `ENV` rather than a managed global config file: it is a security property, and an `ENV` cannot be overridden by anything the deck or a project writes under `/data`. Leave `not_found_system_fallback` at its default `true`. Turning it off would make an unresolvable shim fail — but a shim exists for every executable of every tool mise has *ever* installed, so once any project installs `node`, a hard-failing `node` shim would break every other project that relies on the image's Debian `nodejs`. The cost of keeping the fallback is that a declared-but-uninstalled tool can silently run a system binary of a different version; section 5's context block tells the agent to check with `mise which` rather than `command -v`. No change to `docker/entrypoint.sh`. mise creates its own directories under `/data`, which the deck UID already owns. ### 2. Store a per-workspace mise config A workspace may carry a config pasted in Settings, used on top of (or instead of) whatever the repo declares. Follow the `victorialogs_enabled` chain end to end; the next free migration number is `026` (`025-forgejo-trigger-quota-notice.sql` is the latest). `apps/server/src/db/migrations/026-workspace-mise-config.sql`: ```sql -- A mise config pasted for this workspace in Settings → Workspaces. `mode` -- decides how it meets the repo's own config: 'override' merges it over the -- repo's (mise's native per-tool precedence via mise.local.toml), 'replace' -- makes mise read only this config in the workspace and its checkouts. ALTER TABLE workspaces ADD COLUMN mise_config TEXT; ALTER TABLE workspaces ADD COLUMN mise_config_mode TEXT NOT NULL DEFAULT 'override'; ``` `apps/server/src/db/workspaces.ts`: add `mise_config: string | null` and `mise_config_mode: string` to `WorkspaceRow` (line 22), both names to `WORKSPACE_COLUMNS` (line 44), and to `rowToWorkspace` (line 32) `miseConfig: r.mise_config ?? ""` and `miseConfigMode: r.mise_config_mode === "replace" ? "replace" : "override"`. Add next to `setWorkspaceVictoriaLogsEnabled`: ```ts export function setWorkspaceMiseConfig( workspaceId: string, config: string, mode: MiseConfigMode, ): Workspace | undefined ``` storing `config.trim() === "" ? null : config` and `mode`, returning `getWorkspace(workspaceId)`, and returning `undefined` when the workspace does not exist — identical in shape to the VictoriaLogs setter. `packages/protocol/src/index.ts`: add `export type MiseConfigMode = "override" | "replace";` and extend - `Workspace` (line 54) with `miseConfig: string;` (empty string = none) and `miseConfigMode: MiseConfigMode;` - `WorkspaceDetails` (line 96) with the same two fields plus `mise: MiseStatus;` (section 4) - `UpdateWorkspaceRequest` (line 124) with `miseConfig?: string;` and `miseConfigMode?: MiseConfigMode;` `apps/server/src/routes-workspaces.ts`, in `PATCH /workspaces/:id` (line 104): extend the required-field guard at line 112 to mention `miseConfig`, validate `miseConfig` is a string and `miseConfigMode` is `"override" | "replace"` (400 otherwise), and add a branch after the VictoriaLogs one calling `setWorkspaceMiseConfig(id, body.miseConfig ?? ws.miseConfig, body.miseConfigMode ?? ws.miseConfigMode)`. Either field alone is a valid update. After a successful write, call `void provisionWorkspace(ws)` (section 3) so the change takes effect without reopening a session, then `notifyWorkspacesChanged()` as the existing branches do. `apps/server/src/workspace-details.ts`: add `miseConfig`, `miseConfigMode` and `mise: miseStatus(ws.id)` beside the existing `victorialogsEnabled` field at line 78. `apps/web/src/lib/api.ts`, beside `setWorkspaceVictoriaLogs` (line 63): ```ts setWorkspaceMiseConfig(id: string, miseConfig: string, miseConfigMode: MiseConfigMode): Promise<Workspace> ``` PATCHing `{ miseConfig, miseConfigMode }`. `apps/web/src/components/settings/WorkspacesPanel.tsx`: add a `<dt>mise</dt>` / `<dd>` pair to the `<dl>` after the VictoriaLogs row (ends line 322). Unlike the two checkboxes it needs an explicit commit, so: a `<textarea>` bound to local state seeded from `ws.miseConfig`, a `<select>` with `override` / `replace`, a **Save** button disabled while `busy` or unchanged, and the line `Applies immediately; the workspace is re-provisioned in the background.` Render `ws.mise` underneath as `phase` plus, when `phase === "failed"`, the recorded error. Label the modes in the UI as `override — merged over the repo's own config` and `replace — the repo's own config is ignored`. ### 3. Provision a workspace's toolchain New directory `apps/server/src/mise/`. Nothing here may ever take instructions from a session's cwd. #### 3a. `apps/server/src/mise/materialize.ts` Writes the deck-managed files that make mise resolve the right versions **in a directory**. Exports: ```ts export const MANAGED_HEADER = "# Managed by omp-deck — edited in Settings → Workspaces. Local edits are overwritten."; export async function materializeMiseConfig(dir: string, cfg: { text: string; mode: MiseConfigMode } | null): Promise<void>; ``` Behaviour: - `cfg === null` or `cfg.text.trim() === ""`: delete `<dir>/mise.local.toml` and `<dir>/.miserc.toml`, but **only** if the file exists and its first line is `MANAGED_HEADER`. Return. - Otherwise write `<dir>/mise.local.toml` as `MANAGED_HEADER + "\n" + cfg.text`. mise reads `mise.local.toml` ahead of `mise.toml` in the same directory and documents it as the file for personal, uncommitted overrides, which is exactly this. `[tools]` merges per tool, so a pasted `node` entry replaces the repo's `node` and leaves the repo's other tools in force. - `cfg.mode === "replace"`: additionally write `<dir>/.miserc.toml`: ```toml # Managed by omp-deck — edited in Settings → Workspaces. Local edits are overwritten. override_config_filenames = ["mise.local.toml"] override_tool_versions_filenames = ["none"] ``` `override_config_filenames` makes mise load only files with those names in each directory, and `override_tool_versions_filenames = ["none"]` stops `.tool-versions` being read; both are settable in a project `.miserc.toml` (`rc = true` in mise's settings schema), and `.miserc.toml` can only select which config files load, so it carries no code and needs no trust. `cfg.mode === "override"`: delete a managed `.miserc.toml` if present. - Before overwriting either file, if it exists and does **not** start with `MANAGED_HEADER`, throw `MiseConfigConflict` naming the path, and write nothing. A user's own `mise.local.toml` is never clobbered. - When `<dir>/.git` exists, ensure `.git/info/exclude` contains a block ``` # omp-deck managed mise config /mise.local.toml /.miserc.toml ``` appended once, keyed on that comment line, so the managed files never show up as untracked. Create `.git/info/` if missing. #### 3b. `apps/server/src/mise/provision.ts` ```ts export type MisePhase = "idle" | "running" | "ok" | "failed"; export interface MiseStatus { phase: MisePhase; /** Config files the install was driven from, workspace-relative. */ sources: string[]; /** Set only when phase === "failed". */ error?: string; updatedAt: string; } export function miseStatus(workspaceId: string): MiseStatus; export async function provisionWorkspace(ws: Workspace): Promise<void>; export function kickoffMiseProvisioning(workspaceId: string | null): void; ``` State lives in two module-level maps keyed by workspace id: the `MiseStatus` record, and the in-flight `Promise<void>` so concurrent sessions in one workspace never run two installs (return the existing promise instead of starting a second). Both are in-memory; after a deck restart a workspace reports `idle` until its next session, which is correct — provisioning is idempotent and cheap once warm. `provisionWorkspace(ws)` steps, in order: 1. Set phase `running`. 2. `await materializeMiseConfig(ws.path, ws.miseConfig ? { text: ws.miseConfig, mode: ws.miseConfigMode } : null)`. Do this **first**, so version resolution in the workspace is correct even while the install is still running. A `MiseConfigConflict` here sets phase `failed` with the thrown message and stops. 3. Build a scratch config root at `<getDataDir()>/mise/workspaces/<ws.id>`: `rm -rf` then `mkdir -p`. Installs are driven from this directory and never from `ws.path` or a checkout, because the working tree may sit on any branch and a checkout holds a pull request's code. 4. Unless the mode is `replace`, copy the **default branch's** config into the scratch root. Resolve a ref by trying, in order, `origin/HEAD` and `origin/<default branch>`; use `runGit` from `apps/server/src/bridge/git-tools/run.ts` with `readOnly: true` — it spawns git as an argv array with no shell, which is what every other git caller in the server uses. For each candidate relative path ``` mise.toml .mise.toml mise/config.toml .mise/config.toml .config/mise.toml .config/mise/config.toml .tool-versions mise.lock ``` run `git show <ref>:<rel>` and, on exit 0, write the output into the scratch root at the same relative path. Copy every one that exists rather than picking a winner: that keeps mise's own precedence rules intact instead of inventing one. If neither ref resolves — a workspace with no remote, or not a git work tree at all — fall back to copying the same paths from `ws.path` on disk; there is no pull-request risk in a repo that has no remote. Record the copied paths as `sources`. 5. If the workspace has a pasted config, write it into the scratch root as `mise.local.toml` (header included, same as the materialized one). In `replace` mode step 4 copied nothing, so this is the only config present and no scratch `.miserc.toml` is needed. 6. If the scratch root is empty, set phase `idle` with empty `sources` and return — nothing to provision. 7. Run `mise install --yes` with `cwd` = the scratch root, `MISE_CEILING_PATHS=<getDataDir()>/mise/workspaces` in the environment so the directory walk cannot pick up stray config from `/data`, and a 30-minute timeout. Spawn it as an argv array via `Bun.spawn`, matching how `git-tools/run.ts` spawns git; do not go through a shell. Running `mise install` here auto-trusts the scratch config, which is intended and safe: its content came from the repository's default branch or from the user's own paste. 8. Exit 0 → phase `ok`. Non-zero, timeout, or a spawn failure → phase `failed`, `error` set to the last 2000 characters of stderr, and one `log.warn` naming the workspace. A failed provision never throws to the caller and never blocks a session. `kickoffMiseProvisioning(workspaceId)` looks the workspace up and calls `void provisionWorkspace(ws)` without awaiting. A `null` id is a no-op. #### 3c. Wire it into session creation In `apps/server/src/bridge/in-process.ts`, `integrationWorkspaceId` is already resolved at line 312 — the trigger path through `opts.placement.workspaceId`, the interactive path through `getWorkspaceByPath(opts.cwd)`. Immediately after it, add `kickoffMiseProvisioning(integrationWorkspaceId)`. Do the same at the corresponding line in `resumeSession` (around line 464). Not awaited: `POST /sessions` (`apps/server/src/routes.ts:101`) awaits `createSession`, and the web client awaits that HTTP call (`apps/web/src/lib/store.ts:382`), so a cold Rust or Python toolchain would otherwise freeze "New session" for minutes. `createSession` is also reached from `forgejo/dispatcher.ts:2049`, `:2156`, `:2936` and `forgejo/plan-issue.ts:199`; all of them resolve their workspace through the same variable, so this one call site covers every path. #### 3d. Apply the workspace config inside trigger checkouts A checkout lives outside `workspace.path`, so nothing materialized in the workspace reaches it. In `apps/server/src/forgejo/checkout.ts`, after the local-branch cleanup at the end of `createCheckout` and at the same point in `restoreCheckout`, resolve the workspace with `getWorkspaceByPath(opts.workspacePath)` — no signature change is needed — and call `materializeMiseConfig(dest, …)` with its pasted config, or with `null` when there is none. `createEmptyCheckout` has no workspace and is left alone. In `replace` mode this also makes the pull request's own `mise.toml` inert inside the clone, which is the stronger behaviour, not a weaker one. Swallow and log a `MiseConfigConflict` here rather than failing the checkout: a clone is disposable and a broken toolchain must not block a trigger session from opening. ### 4. Tell the agent where tools come from The guidance must **not** go into `DEFAULT_PRELUDE`: `getEffectivePrelude()` (`apps/server/src/orientation-store.ts`) returns `<dataDir>/prelude.md` whenever it exists, so anyone who has customised their prelude would never see an edit to the default. Add it as its own entry in the system-prompt array instead, the way `forgejoContext` already is. New `apps/server/src/mise/system-context.ts` exporting `miseSessionContext(workspaceId: string | null): string | null`, returning `null` when there is no workspace. Otherwise the static block below, followed by a status line built from `miseStatus(workspaceId)` — `Workspace tool provisioning: <phase>.`, plus `Driven from: <sources joined>.` when non-empty, plus the recorded error when `failed`. ``` ## Dev tools (mise) Dev tools in this container come from mise, not from apt. The image is deliberately small, the session has no root, and anything apt installed would be lost when the container is recreated. `$HOME` is `/data`, a bind mount, so everything mise installs persists. - mise shims lead `PATH`. A tool a project declares resolves to that project's version automatically, per directory, with no activation step. - Shims never auto-install here. A declared-but-uninstalled tool falls through to a same-named system binary, which may be a different version. Check with `mise which <tool>`, never `command -v <tool>` — `command -v` succeeds for any tool that ever had a shim, whether or not it resolves here. - To use a tool right now: `mise exec <tool>@<version> -- <command>`, which installs it on demand. To keep it for later: `mise install <tool>@<version>`, and `mise use --global <tool>@<version>` to make it resolve everywhere. - `[env]` values from a project's config reach shimmed programs, not the ambient shell. A build that needs them must run through `mise exec --` or `mise run`. - A tool this project should always have belongs in its own mise config, not in a global install. ``` Wire it into both `systemPrompt` arrays in `in-process.ts` — line 334 and the resume copy near line 480 — directly after the `forgejoContext` entry: ```ts ...(miseContext ? [miseContext] : []), ``` Call `miseSessionContext(integrationWorkspaceId)` **inside** the closure, not before it, so the status refreshes as provisioning progresses: the resolver is invoked per turn preparation (`node_modules/@oh-my-pi/pi-coding-agent/src/sdk.ts:4411`), which is also why the existing `ideaBridgeRef.current?.isEnabled()` check in that array works despite the ref being `null` at create time. *Unverified — confirm first:* if the closure turns out to run once at creation, the status cannot update that way; in that case keep the static block in the closure and move the status line into a small extension registered alongside `gate` in the `extensions:` array (line 343) that handles `before_agent_start` and returns `{ systemPrompt: [...event.systemPrompt, statusLine] }`. Do not add a push-to-live-session mechanism for this. ### 5. Verification Everything below runs against a container built from this branch. Prerequisite for all of it: `docker build -t omp-deck .` and a run with `-v <host-dir>:/data` as the `Dockerfile` header describes. 1. **mise is present and persists.** In a session's bash: `mise --version` prints the pinned `2026.11.7`, and `mise doctor` reports its data directory as `/data/.local/share/mise` and its shims directory as `/data/.local/share/mise/shims`. `echo $PATH` shows that shims path first. Recreate the container (`docker rm -f` + run again) and confirm `mise ls --global` still lists anything installed before the recreate. 2. **Auto-install is off for shims but on for exec.** In a directory with a `mise.toml` declaring a tool that is not installed, running the tool directly must not install it; `mise exec <tool>@<version> -- <tool> --version` must install and print a version. 3. **A repo's own config provisions.** Register a workspace whose default branch commits `mise.toml` with, say, `[tools]\njq = "1.7.1"`. Open a session in it. Within the install's runtime, `mise which jq` inside the workspace resolves to a path under `/data/.local/share/mise/installs/jq/1.7.1`, and `jq --version` prints `jq-1.7.1` rather than the image's Debian `jq`. `GET /api/workspaces/details` shows that workspace's `mise.phase` as `ok` with `mise.toml` in `sources`. 4. **A pull request cannot drive an install.** On a branch of that workspace's repo, push a commit changing `mise.toml` to a version that is not installed, open a pull request, and let a trigger session start. In its checkout, the tool must **not** be installed: `ls /data/.local/share/mise/installs/jq` still shows only the default branch's version, and the deck log contains no `mise install` for the checkout path. This is the security property the design exists for; check it explicitly. 5. **Pasted config, override mode.** In Settings → Workspaces, paste `[tools]\njq = "1.7"` for a workspace whose repo declares `jq` and something else, leave the mode on `override`, Save. `<workspace>/mise.local.toml` appears carrying the managed header, `git status` in the workspace reports a clean tree (the file is excluded), `mise which jq` resolves to the pasted version, and the repo's other tool still resolves. 6. **Pasted config, replace mode.** Switch the same workspace to `replace` and Save. `<workspace>/.miserc.toml` appears with `override_config_filenames = ["mise.local.toml"]`, `mise config ls` in the workspace lists only `mise.local.toml`, and the tool the repo declared but the paste does not no longer resolves. Clear the textarea and Save: both managed files are gone and the repo's config resolves again. 7. **Checkouts inherit the paste.** With a paste in place, trigger a new pull-request session and confirm `mise.local.toml` exists in `<dataDir>/checkouts/<owner>/<repo>/pr-<n>/` with the managed header. 8. **A user's own file is never clobbered.** Hand-write a `mise.local.toml` without the managed header into a workspace, then Save a paste for it. The file must be untouched, and that workspace's `mise.phase` must be `failed` with an error naming the path. 9. **Failure never blocks.** Paste a config naming a tool that does not exist (`[tools]\nnot-a-real-tool = "1"`), Save, and open a session. Session creation must succeed promptly, the session's system context must carry `Workspace tool provisioning: failed` with the error text, and the deck log must carry the warning. 10. **Existing behaviour is intact.** `bun test` in the repo root passes, including `apps/server/src/forgejo/dispatcher.test.ts`, whose bridge fake exercises the `createSession` path the provisioning kickoff was added to. ### Assumptions & contingencies - **mise's `.miserc.toml` honours `override_config_filenames` from a project directory.** Both settings are marked `rc = true` in mise's settings schema, which is what permits them in a `.miserc.toml`. If step 6 of Verification shows the repo's `mise.toml` still loading in `replace` mode, fall back to `ignored_config_paths` in the same `.miserc.toml`, listing the repo config paths the deck found in step 4 of provisioning; it is `rc = true` as well and its relative paths resolve from the directory holding the file. - **`git show origin/HEAD:…` resolves in a workspace.** This depends on `refs/remotes/origin/HEAD` existing, which a plain `git clone` creates but a repo set up by hand may lack. The ordered fallback to `origin/<default branch>` and then to the on-disk files covers it, so no step stalls; if `origin/HEAD` turns out to be missing often in practice, read the default branch name from the existing Forgejo repo metadata rather than adding a `git remote set-head` call — the deck must not mutate the user's repository. - The deck never fetches in a workspace, so the default-branch config it reads is as fresh as the user's last fetch. That is intentional: fetching would be a write to the user's repository. If a provision looks stale, the fix is a `git fetch` the user runs.
Author
Owner

@omp-deck What are some ideas to provide dev tools in a way other than installing them in Dockerfile

@omp-deck What are some ideas to provide dev tools in a way other than installing them in `Dockerfile`
Collaborator

Ideas, grouped by the mechanism they use. Most of them are complementary rather than exclusive — I'd expect the end state to be two or three of these combined.

First, one structural fact that makes several of these cheap: the entrypoint runs the deck (and therefore the agent's shell) with HOME=/data, and /data is the bind-mounted volume. So anything a tool installs under $HOME already persists across container recreation, without touching the image. The current image fights that: CARGO_HOME=/usr/local/cargo and the pytools venv in /opt/pytools are baked into layers, so cargo install / pip install by the agent lands in the ephemeral upper layer and is lost on recreate. Moving the mutable toolchain roots under /data is a precondition for most of the "install on demand" options below.


1. Per-project toolchain manager (mise / devbox / nix)

Install exactly one small bootstrapper in the image; projects declare their tools in a file they own (mise.toml, .tool-versions, devbox.json, flake.nix). Cache under /data (MISE_DATA_DIR=/data/.local/share/mise) so the first session pays the download and later ones don't.

  • hits all three complaints: image stays small, the project controls its own tool list, no omp-deck change per project
  • mise is the cheapest to adopt (single static binary, Debian-friendly, has a large builtin registry plus ubi/cargo/npm/pipx backends); nix is the most reproducible but is a much bigger conceptual and disk commitment
  • cost: first-use latency, needs network at session time, and the agent must actually pick up the env (mise activate in the shell init, or mise x -- wrappers). A deck-side "run mise install when a session's cwd changes" hook would make this invisible.

2. Ephemeral per-invocation runners

Lean on the runners that are already there (or nearly): bunx/npx, uvx/pipx run, go run, cargo-binstall. uvx ruff check replaces the entire pytools stage; npx already replaces the global typescript-language-server/pyright installs.

  • near-zero image cost, projects pin versions in their own manifests
  • needs the caches on /data (UV_CACHE_DIR, npm_config_cache, XDG_CACHE_HOME) or every invocation re-downloads
  • the agent has to know to use them — this is a prompt/skill/AGENTS.md problem as much as an infra one.

3. Host-managed tool volume

Mount a host directory of static binaries read-only at e.g. /opt/host-tools and prepend it to PATH via compose. Host stays the single place you install things.

  • no image growth, no rebuild, instant
  • big caveat on your box: the host is Arch, the image is Debian-based oven/bun. Dynamically linked Arch binaries will fail against bookworm's older glibc. This only works reliably for statically linked tools (Go, most Rust, jq, just, …), which is a real but limited subset. Mounting host /usr wholesale is not viable.

4. Derived image instead of upstream image

Keep the published omp-deck image minimal (bun + git + ripgrep + the runtime essentials) and document a one-line local Dockerfile:

FROM ghcr.io/…/omp-deck:latest
RUN apt-get install -y … && cargo install …

plus optional --build-arg/build targets for the common bundles (rust, python, web). Doesn't solve per-project granularity, but it does solve "requires a change in omp-deck" and lets the published image shrink a lot (build-essential + chromium + rustup dominate the current size).

5. Tools as containers (docker-out-of-docker)

Give the agent a docker/podman CLI and have it run tools as throwaway containers against the bind-mounted workspace. Projects declare a toolbox image; omp-deck ships nothing.

  • maximal isolation per project, zero image growth
  • mounting the host docker socket is effectively handing the agent root on the host — I'd only consider this with rootless podman inside the container, or a dedicated socket-proxy with a strict allowlist. Also path translation is a trap: -v $PWD:/w inside the container needs the host path, not the container path.

6. Full devcontainer per project

Let the project's existing .devcontainer/ define the environment, and have the deck run the session inside that container rather than in its own.

  • the most "correct" answer to "controlled by the project individually", and reuses an ecosystem projects often already have
  • by far the largest change to omp-deck's architecture: sessions currently run in-process in the deck server, so this means a real exec/transport boundary, lifecycle management, and rethinking how the agent's tools reach the workspace. Worth listing, probably not worth doing soon.

7. Project setup hook

A conventional per-project script (.omp-deck/setup.sh, or a routine triggered on first session in a workspace) that the deck runs once per workspace and caches a marker for. Not a tool-provisioning mechanism itself — it's the trigger that makes 1/2/3 ergonomic, and it's the smallest thing that gives projects control without an omp-deck change.

8. Reach back to the host over SSH

The deck already ships openssh-client and the agent has ssh:// support. A project could declare "run builds on the host". Avoids duplication entirely, but it's a shell on the dev machine from inside the sandbox — it throws away the containment that the container was for. Mentioning for completeness; I wouldn't.


What I'd actually do

  1. Move mutable tool roots onto /data (CARGO_HOME, UV_CACHE_DIR, npm cache, MISE_DATA_DIR, XDG_*). Cheap, no downside, and every other option depends on it.
  2. Strip the image to a floor — bun, git, ripgrep, curl, ca-certs, jq, python3, nodejs, a C toolchain only if something actually needs it. Drop the language-server npm globals and the pytools venv in favour of npx/uvx. chromium and build-essential deserve a hard look; both are large and probably not universally needed.
  3. Add mise (or uv + mise) as the single provisioning primitive, plus a setup hook (7) so entering a workspace with a mise.toml provisions it automatically into the persisted volume.
  4. Document the derived-image escape hatch (4) for anything that genuinely must be in the image (system libs, apt packages).

That leaves omp-deck with one mechanism to maintain, keeps the image boring, and makes the tool list a property of each project's repo.

Open questions worth deciding before implementing: whether tool caches are shared across projects or isolated per workspace (shared is smaller and faster, isolated avoids cross-project version fights); whether first-session provisioning may block or must be backgrounded; and whether sessions are allowed network access for on-demand installs at all.

Ideas, grouped by the mechanism they use. Most of them are complementary rather than exclusive — I'd expect the end state to be two or three of these combined. First, one structural fact that makes several of these cheap: the entrypoint runs the deck (and therefore the agent's shell) with `HOME=/data`, and `/data` is the bind-mounted volume. So **anything a tool installs under `$HOME` already persists across container recreation**, without touching the image. The current image fights that: `CARGO_HOME=/usr/local/cargo` and the pytools venv in `/opt/pytools` are baked into layers, so `cargo install` / `pip install` by the agent lands in the ephemeral upper layer and is lost on recreate. Moving the mutable toolchain roots under `/data` is a precondition for most of the "install on demand" options below. --- ## 1. Per-project toolchain manager (`mise` / `devbox` / `nix`) Install exactly one small bootstrapper in the image; projects declare their tools in a file they own (`mise.toml`, `.tool-versions`, `devbox.json`, `flake.nix`). Cache under `/data` (`MISE_DATA_DIR=/data/.local/share/mise`) so the first session pays the download and later ones don't. - hits all three complaints: image stays small, the project controls its own tool list, no omp-deck change per project - `mise` is the cheapest to adopt (single static binary, Debian-friendly, has a large builtin registry plus ubi/cargo/npm/pipx backends); `nix` is the most reproducible but is a much bigger conceptual and disk commitment - cost: first-use latency, needs network at session time, and the agent must actually pick up the env (`mise activate` in the shell init, or `mise x --` wrappers). A deck-side "run `mise install` when a session's cwd changes" hook would make this invisible. ## 2. Ephemeral per-invocation runners Lean on the runners that are already there (or nearly): `bunx`/`npx`, `uvx`/`pipx run`, `go run`, `cargo-binstall`. `uvx ruff check` replaces the entire `pytools` stage; `npx` already replaces the global `typescript-language-server`/`pyright` installs. - near-zero image cost, projects pin versions in their own manifests - needs the caches on `/data` (`UV_CACHE_DIR`, `npm_config_cache`, `XDG_CACHE_HOME`) or every invocation re-downloads - the agent has to *know* to use them — this is a prompt/skill/`AGENTS.md` problem as much as an infra one. ## 3. Host-managed tool volume Mount a host directory of static binaries read-only at e.g. `/opt/host-tools` and prepend it to `PATH` via compose. Host stays the single place you install things. - no image growth, no rebuild, instant - **big caveat on your box**: the host is Arch, the image is Debian-based `oven/bun`. Dynamically linked Arch binaries will fail against bookworm's older glibc. This only works reliably for statically linked tools (Go, most Rust, `jq`, `just`, …), which is a real but limited subset. Mounting host `/usr` wholesale is not viable. ## 4. Derived image instead of upstream image Keep the published `omp-deck` image minimal (bun + git + ripgrep + the runtime essentials) and document a one-line local Dockerfile: ```dockerfile FROM ghcr.io/…/omp-deck:latest RUN apt-get install -y … && cargo install … ``` plus optional `--build-arg`/build targets for the common bundles (`rust`, `python`, `web`). Doesn't solve per-project granularity, but it does solve "requires a change in omp-deck" and lets the published image shrink a lot (`build-essential` + `chromium` + rustup dominate the current size). ## 5. Tools as containers (docker-out-of-docker) Give the agent a `docker`/`podman` CLI and have it run tools as throwaway containers against the bind-mounted workspace. Projects declare a toolbox image; omp-deck ships nothing. - maximal isolation per project, zero image growth - mounting the host docker socket is effectively handing the agent root on the host — I'd only consider this with rootless podman inside the container, or a dedicated socket-proxy with a strict allowlist. Also path translation is a trap: `-v $PWD:/w` inside the container needs the *host* path, not the container path. ## 6. Full devcontainer per project Let the project's existing `.devcontainer/` define the environment, and have the deck run the session inside that container rather than in its own. - the most "correct" answer to "controlled by the project individually", and reuses an ecosystem projects often already have - by far the largest change to omp-deck's architecture: sessions currently run in-process in the deck server, so this means a real exec/transport boundary, lifecycle management, and rethinking how the agent's tools reach the workspace. Worth listing, probably not worth doing soon. ## 7. Project setup hook A conventional per-project script (`.omp-deck/setup.sh`, or a routine triggered on first session in a workspace) that the deck runs once per workspace and caches a marker for. Not a tool-provisioning mechanism itself — it's the *trigger* that makes 1/2/3 ergonomic, and it's the smallest thing that gives projects control without an omp-deck change. ## 8. Reach back to the host over SSH The deck already ships `openssh-client` and the agent has `ssh://` support. A project could declare "run builds on the host". Avoids duplication entirely, but it's a shell on the dev machine from inside the sandbox — it throws away the containment that the container was for. Mentioning for completeness; I wouldn't. --- ## What I'd actually do 1. **Move mutable tool roots onto `/data`** (`CARGO_HOME`, `UV_CACHE_DIR`, npm cache, `MISE_DATA_DIR`, `XDG_*`). Cheap, no downside, and every other option depends on it. 2. **Strip the image to a floor** — bun, git, ripgrep, curl, ca-certs, jq, python3, nodejs, a C toolchain only if something actually needs it. Drop the language-server npm globals and the `pytools` venv in favour of `npx`/`uvx`. `chromium` and `build-essential` deserve a hard look; both are large and probably not universally needed. 3. **Add `mise` (or `uv` + `mise`) as the single provisioning primitive**, plus a setup hook (7) so entering a workspace with a `mise.toml` provisions it automatically into the persisted volume. 4. **Document the derived-image escape hatch** (4) for anything that genuinely must be in the image (system libs, apt packages). That leaves omp-deck with one mechanism to maintain, keeps the image boring, and makes the tool list a property of each project's repo. Open questions worth deciding before implementing: whether tool caches are shared across projects or isolated per workspace (shared is smaller and faster, isolated avoids cross-project version fights); whether first-session provisioning may block or must be backgrounded; and whether sessions are allowed network access for on-demand installs at all.
Author
Owner

Mise sounds like a good solution, especially when integrated into the session handling in a way that the harness handles it mostly transparently. Including things like "if a tool is missing and evidence suggests the user wants it available, open a pr to adjust the mise config"

Mise sounds like a good solution, especially when integrated into the session handling in a way that the harness handles it mostly transparently. Including things like "if a tool is missing and evidence suggests the user wants it available, open a pr to adjust the mise config"
Collaborator

Agreed on mise. Here's what "mostly transparent" has to mean concretely, given how sessions actually work in this repo, plus a design for the PR-opening loop.

The constraint that shapes the whole design

Sessions run in-process in the deck server (InProcessAgentBridge.createSession), so every session shares one process.env. There is no per-session environment today, and adding one means reaching into how the SDK spawns the bash tool. That rules out mise activate (shell-hook based, one env per shell) as the integration path.

Use shims instead. A shim resolves the tool version from the invocation's current directory, not from an ambient activated env (mise docs). So one global PATH entry — $MISE_DATA_DIR/shims, prepended ahead of /usr/local/cargo/bin and friends — gives two concurrent sessions in two different workspaces two different toolchains, with zero per-session plumbing. That's the piece that makes this cheap.

Known shim limitations, worth accepting explicitly rather than discovering later:

  • shims cover binaries; they don't export mise.toml's [env] into the ambient environment for processes that aren't themselves shimmed
  • tools that introspect PATH or expect GOROOT/VIRTUAL_ENV-shaped discovery can behave oddly behind a shim
  • the escape hatch for both is mise exec -- / mise run, which the prelude should teach as the fallback

Layering

0 — Image + persistence. mise binary in the image (one static binary). MISE_DATA_DIR / MISE_CACHE_DIR under /data so installs survive container recreation. Shims first on PATH. This alone is a small change and is a prerequisite for everything else.

1 — Provision on session create. createSession already has opts.cwd. If the workspace has mise.toml / .tool-versions, run mise install --locked.

Two things to get right here:

  • Don't block session creation. Provisioning a Python or a Rust toolchain is minutes. Run it async, and have session_start inject the status into context ("provisioning node 24, go 1.24 — in progress") so the agent doesn't start a build that fails for a reason it can't see.
  • Trust is the real problem. mise.toml can run arbitrary code (tasks, hooks, source-building backends). Auto-running mise install on any workspace the deck can see is remote code execution from whatever repo got checked out — including, notably, trigger-session checkouts of PR branches from the Forgejo dispatcher. mise has its own mise trust model; the deck needs to mirror it as an explicit per-workspace opt-in stored with the workspace record and visible in the UI, and trigger checkouts should default to untrusted regardless.

2 — Context, not probing. session_start injects the resolved tool list. Prelude guidance: a missing tool means mise, never apt-get (which won't persist anyway). Cheap, and it removes a whole class of which foo round-trips.

3 — Missing tool → PR. This is the interesting part, and the failure mode is obvious: an agent that opens a PR because it fat-fingered pyhton. So the loop needs real evidence gating.

Signal. The extension API already has what's needed — the tool_call hook (see starter-extensions/maintenance-gate) can watch bash results for exit 127 / command not found and record (workspace, binary, command) into a small ledger.

Filters, all of which must pass:

  • the binary exists in mise's registry — otherwise mise isn't the answer and a PR would be noise
  • it isn't already in the project's config (that's a provisioning failure, a different bug, and should be reported as one)
  • it isn't a near-miss typo of something already on PATH or in the registry
  • evidence: either the user asked for it in so many words, or it appears in the project's own files — package.json scripts, justfile, Makefile, CI workflow. A project that runs golangci-lint in CI clearly wants it; a speculative which fzf does not.

Escalating response, so the PR is the last resort rather than the first:

  1. unblock now with mise x <tool>@latest -- …, change nothing
  2. if it recurs, install into the user/global mise config under /data — persists, still no PR
  3. only on repeated use plus project-file evidence, mise use --pin <tool>@<version> and open a PR via the existing git_* + Forgejo chain

Two judgement calls that decide whether this is useful or annoying:

  • Which config. Project tools belong in the repo's mise.toml; personal preferences (fzf, delta, a pager) belong in the global config. Conflating them produces PRs nobody wants. My proposed rule: a PR against the project only when the tool appears in the project's own files.
  • Batching. Accumulate within a session and flush one PR at turn_end, never one PR per discovery.

Pin versions, don't float — a PR that writes node = "latest" is worse than no PR.

What mise doesn't cover

System-level apt packages (libssl-dev, headless chromium) and the C toolchain. mise's cargo: / npm: backends build from source and therefore need build-essential, which is the main argument for keeping it in the image despite the size. So the derived-image escape hatch from my earlier comment stays necessary; mise shrinks the problem, it doesn't delete it. Same for air-gapped or offline sessions, which would need a pre-warmed /data.

Smallest thing worth shipping

Layers 0 and 2, plus Layer 1 behind an explicit per-workspace trust toggle. That already delivers the original three complaints — small image, project-owned tool list, no omp-deck change per project.

I'd hold Layer 3 until there's a ledger of what actually goes missing in practice. The detector is only as good as its evidence rules, and designing those from imagination rather than from a few weeks of recorded command not found events is how you get a bot that opens PRs for typos. The ledger from Layer 3's signal half is cheap to build and useful on its own — ship the observation, defer the action.

Open questions I'd want settled before implementation: trust model for trigger-session checkouts specifically; whether tool installs are shared across workspaces or isolated; whether provisioning may ever block session start; and whether mise.lock is committed per project or left out.

Happy to split these into separate issues (image/persistence, session provisioning + trust, missing-tool ledger) if you want them tracked independently.

Agreed on mise. Here's what "mostly transparent" has to mean concretely, given how sessions actually work in this repo, plus a design for the PR-opening loop. ## The constraint that shapes the whole design Sessions run **in-process** in the deck server (`InProcessAgentBridge.createSession`), so every session shares one `process.env`. There is no per-session environment today, and adding one means reaching into how the SDK spawns the bash tool. That rules out `mise activate` (shell-hook based, one env per shell) as the integration path. **Use shims instead.** A shim resolves the tool version from the *invocation's* current directory, not from an ambient activated env ([mise docs](https://mise.jdx.dev/dev-tools/shims.html)). So one global `PATH` entry — `$MISE_DATA_DIR/shims`, prepended ahead of `/usr/local/cargo/bin` and friends — gives two concurrent sessions in two different workspaces two different toolchains, with zero per-session plumbing. That's the piece that makes this cheap. Known shim limitations, worth accepting explicitly rather than discovering later: - shims cover binaries; they don't export `mise.toml`'s `[env]` into the ambient environment for processes that aren't themselves shimmed - tools that introspect `PATH` or expect `GOROOT`/`VIRTUAL_ENV`-shaped discovery can behave oddly behind a shim - the escape hatch for both is `mise exec --` / `mise run`, which the prelude should teach as the fallback ## Layering **0 — Image + persistence.** `mise` binary in the image (one static binary). `MISE_DATA_DIR` / `MISE_CACHE_DIR` under `/data` so installs survive container recreation. Shims first on `PATH`. This alone is a small change and is a prerequisite for everything else. **1 — Provision on session create.** `createSession` already has `opts.cwd`. If the workspace has `mise.toml` / `.tool-versions`, run `mise install --locked`. Two things to get right here: - *Don't block session creation.* Provisioning a Python or a Rust toolchain is minutes. Run it async, and have `session_start` inject the status into context ("provisioning node 24, go 1.24 — in progress") so the agent doesn't start a build that fails for a reason it can't see. - *Trust is the real problem.* `mise.toml` can run arbitrary code (tasks, hooks, source-building backends). Auto-running `mise install` on any workspace the deck can see is remote code execution from whatever repo got checked out — including, notably, trigger-session checkouts of PR branches from the Forgejo dispatcher. mise has its own `mise trust` model; the deck needs to mirror it as an explicit per-workspace opt-in stored with the workspace record and visible in the UI, and trigger checkouts should default to untrusted regardless. **2 — Context, not probing.** `session_start` injects the resolved tool list. Prelude guidance: a missing tool means `mise`, never `apt-get` (which won't persist anyway). Cheap, and it removes a whole class of `which foo` round-trips. **3 — Missing tool → PR.** This is the interesting part, and the failure mode is obvious: an agent that opens a PR because it fat-fingered `pyhton`. So the loop needs real evidence gating. *Signal.* The extension API already has what's needed — the `tool_call` hook (see `starter-extensions/maintenance-gate`) can watch `bash` results for exit 127 / `command not found` and record `(workspace, binary, command)` into a small ledger. *Filters, all of which must pass:* - the binary exists in mise's registry — otherwise mise isn't the answer and a PR would be noise - it isn't already in the project's config (that's a provisioning failure, a different bug, and should be reported as one) - it isn't a near-miss typo of something already on `PATH` or in the registry - **evidence**: either the user asked for it in so many words, or it appears in the project's own files — `package.json` scripts, `justfile`, `Makefile`, CI workflow. A project that runs `golangci-lint` in CI clearly wants it; a speculative `which fzf` does not. *Escalating response, so the PR is the last resort rather than the first:* 1. unblock now with `mise x <tool>@latest -- …`, change nothing 2. if it recurs, install into the *user/global* mise config under `/data` — persists, still no PR 3. only on repeated use plus project-file evidence, `mise use --pin <tool>@<version>` and open a PR via the existing `git_*` + Forgejo chain *Two judgement calls that decide whether this is useful or annoying:* - **Which config.** Project tools belong in the repo's `mise.toml`; personal preferences (`fzf`, `delta`, a pager) belong in the global config. Conflating them produces PRs nobody wants. My proposed rule: a PR against the project only when the tool appears in the project's own files. - **Batching.** Accumulate within a session and flush one PR at `turn_end`, never one PR per discovery. Pin versions, don't float — a PR that writes `node = "latest"` is worse than no PR. ## What mise doesn't cover System-level `apt` packages (`libssl-dev`, headless chromium) and the C toolchain. mise's `cargo:` / `npm:` backends build from source and therefore *need* `build-essential`, which is the main argument for keeping it in the image despite the size. So the derived-image escape hatch from my earlier comment stays necessary; mise shrinks the problem, it doesn't delete it. Same for air-gapped or offline sessions, which would need a pre-warmed `/data`. ## Smallest thing worth shipping Layers 0 and 2, plus Layer 1 behind an explicit per-workspace trust toggle. That already delivers the original three complaints — small image, project-owned tool list, no omp-deck change per project. I'd hold Layer 3 until there's a ledger of what *actually* goes missing in practice. The detector is only as good as its evidence rules, and designing those from imagination rather than from a few weeks of recorded `command not found` events is how you get a bot that opens PRs for typos. The ledger from Layer 3's signal half is cheap to build and useful on its own — ship the observation, defer the action. Open questions I'd want settled before implementation: trust model for trigger-session checkouts specifically; whether tool installs are shared across workspaces or isolated; whether provisioning may ever block session start; and whether `mise.lock` is committed per project or left out. Happy to split these into separate issues (image/persistence, session provisioning + trust, missing-tool ledger) if you want them tracked independently.
lucas closed this issue 2026-11-12 21:33:25 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#231
No description provided.