Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ Isolation is implemented using `systemd-run` (for resource constraints) and
[`bwrap`](https://github.com/containers/bubblewrap) (for read/write isolation). These tools are **Linux only**, so
*multicode* will not work on other operating systems.

On newer Apple Silicon Macs, there is also an experimental Apple `container` runtime backend. It
reuses the existing `[isolation]` configuration for readable, writable, isolated, and `tmpfs`
paths, and maps CPU / memory limits onto container allocation settings:

```toml
[runtime]
backend = "apple-container"
image = "ghcr.io/example/multicode-java25:latest"

[isolation]
writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"]
readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"]
isolated = ["~/.local/share/opencode", "~/.local/state/opencode"]
tmpfs = ["/tmp"]
inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"]
memory-max = "16 GiB"
cpu = "300%"
```

Mounting `~/.config/opencode` read-only lets the container see the same profiles, models,
skills, and other OpenCode configuration as the host. This is useful if you manage local
profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode`
isolated so session state remains per-workspace.

Apple workspaces also expose the host `~/.gitconfig` automatically. The runtime mounts it through
an internal read-only path and sets `GIT_CONFIG_GLOBAL` so git can use your host global identity
and defaults without requiring a direct file bind.

## Git / GitHub integration

With the GitHub integration you can see progress at a glance in the overview screen, and navigate to the issue or PR
Expand Down
57 changes: 57 additions & 0 deletions apple-container/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
FROM node:22-bookworm-slim AS node

FROM ghcr.io/graalvm/native-image-community:25

ARG HOST_UID=1000
ARG HOST_GID=1000
ARG GH_VERSION=2.83.2

COPY --from=node /usr/local/ /usr/local/

RUN set -eux; \
microdnf install -y \
bash \
ca-certificates \
curl \
git \
openssh-clients \
procps-ng \
rsync \
shadow-utils \
tar \
tmux \
unzip \
xz \
zstd; \
microdnf clean all; \
arch="$(uname -m)"; \
case "${arch}" in \
aarch64|arm64) gh_arch="arm64" ;; \
x86_64|amd64) gh_arch="amd64" ;; \
*) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \
-o /tmp/gh.tar.gz; \
tar -xzf /tmp/gh.tar.gz -C /tmp; \
install "/tmp/gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \
rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${gh_arch}"; \
npm install -g opencode-ai; \
if ! getent group "${HOST_GID}" >/dev/null; then \
groupadd --gid "${HOST_GID}" multicode; \
fi; \
useradd \
--uid "${HOST_UID}" \
--gid "${HOST_GID}" \
--create-home \
--shell /bin/bash \
multicode

ENV HOME=/home/multicode
ENV USER=multicode
ENV PATH=/usr/local/bin:${PATH}

USER multicode
WORKDIR /workspace
ENTRYPOINT []

CMD ["/bin/bash"]
11 changes: 11 additions & 0 deletions apple-container/build-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)

exec container build \
-t multicode-java25:latest \
-f "$SCRIPT_DIR/Containerfile" \
--build-arg "HOST_UID=$(id -u)" \
--build-arg "HOST_GID=$(id -g)" \
"$SCRIPT_DIR"
12 changes: 9 additions & 3 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ workspace-directory = "~/dev/agent-work"
opencode = ["opencode-cli", "opencode"]
# todo: find a solution that isn't bound to TUI lifecycle

[runtime]
backend = "apple-container"
# Local Apple container image. It should contain Java 25, git, gh, and opencode.
image = "multicode-java25:latest"

[github]
#token = {command = "gh auth token"}
token = {env = "GITHUB_MCP_TOKEN"}
Expand Down Expand Up @@ -30,6 +35,7 @@ isolated = [
"~/.local/state/opencode",
]
readable = [
"~/.config/opencode",
"~/.local/share/opencode/auth.json",
]
tmpfs = [
Expand All @@ -38,8 +44,8 @@ tmpfs = [
]
inherit-env = [
"XDG_RUNTIME_DIR",
"DISPLAY",
"HOME",
"PATH",
"LANG",
"TERM",
"COLORTERM",
Expand All @@ -51,9 +57,9 @@ cpu = "300%"


[handler]
review = "/usr/bin/smerge ."
review = "/usr/bin/open ."
review-pty = false
web = "/usr/bin/firefox {}"
web = "/usr/bin/open {}"

[[tool]]
type = "exec"
Expand Down
1 change: 1 addition & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ tracing = "0"
tracing-subscriber = { version = "0", features = ["fmt", "ansi"] }
shell-words = "1"
size = "0"
base64 = "0.22"

[build-dependencies]
openapiv3 = "2"
Expand Down
33 changes: 31 additions & 2 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub use remote_action::{
pub use services::root_session_service::RootSessionStatus;
pub use services::workspace_archive::WorkspaceArchiveFormat;

use std::{fmt, sync::Arc, time::SystemTime};
use std::{collections::BTreeMap, fmt, sync::Arc, time::SystemTime};

use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -90,10 +90,39 @@ impl Default for PersistentWorkspaceSnapshot {

/// Workspace metadata that is saved in transient storage (`/run`) and does not survive a reboot.
/// This is useful for process metadata.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RuntimeBackend {
#[default]
LinuxSystemdBwrap,
AppleContainer,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHandleSnapshot {
#[serde(default)]
pub backend: RuntimeBackend,
#[serde(default, alias = "unit")]
pub id: String,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
}

impl Default for RuntimeHandleSnapshot {
fn default() -> Self {
Self {
backend: RuntimeBackend::default(),
id: String::new(),
metadata: BTreeMap::new(),
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransientWorkspaceSnapshot {
pub uri: String,
pub unit: String,
#[serde(flatten)]
pub runtime: RuntimeHandleSnapshot,
}

/// Holder for the HTTP connection to the opencode server.
Expand Down
13 changes: 10 additions & 3 deletions lib/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ impl WorkspaceManager {
#[cfg(test)]
mod tests {
use super::*;
use crate::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot};
use crate::{
PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot,
TransientWorkspaceSnapshot,
};

#[test]
fn add_notifies_workspace_set_watch() {
Expand Down Expand Up @@ -190,7 +193,11 @@ mod tests {
snapshot.persistent.description = "incrementally updated".to_string();
snapshot.transient = Some(TransientWorkspaceSnapshot {
uri: "http://opencode:secret@127.0.0.1:31337/".to_string(),
unit: "run-u42.service".to_string(),
runtime: RuntimeHandleSnapshot {
backend: RuntimeBackend::LinuxSystemdBwrap,
id: "run-u42.service".to_string(),
metadata: Default::default(),
},
});
true
});
Expand All @@ -199,7 +206,7 @@ mod tests {
let updated = workspace_rx.borrow_and_update().clone();
assert_eq!(updated.persistent.description, "incrementally updated");
assert_eq!(
updated.transient.as_ref().map(|t| t.unit.as_str()),
updated.transient.as_ref().map(|t| t.runtime.id.as_str()),
Some("run-u42.service")
);
assert!(!workspace_set_rx.has_changed().expect("watch still open"));
Expand Down
Loading