Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- `agent-vm setup`: Added `code-opencode` preinstall option to install the OpenCode VS Code extension (`sst-dev.opencode`) into code-server (#37).

## 0.3.0

agent-vm 0.3 is one Go binary with Lima built in. The
Expand Down
13 changes: 8 additions & 5 deletions agent-vm.setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ INSTALL_PI="${AGENT_VM_INSTALL_PI:-0}"
INSTALL_CODE_SERVER="${AGENT_VM_INSTALL_CODE_SERVER:-0}"
# Each agent's code-server extension, which brings its own copy of the agent.
INSTALL_CODE_CLAUDE="${AGENT_VM_INSTALL_CODE_CLAUDE:-0}"
INSTALL_CODE_OPENCODE="${AGENT_VM_INSTALL_CODE_OPENCODE:-0}"
INSTALL_CODE_CODEX="${AGENT_VM_INSTALL_CODE_CODEX:-0}"
INSTALL_CODE_VIBE="${AGENT_VM_INSTALL_CODE_VIBE:-0}"
# An extension needs the editor: `agent-vm setup` says so already, a run of
# this script on its own may not.
if [[ "$INSTALL_CODE_CLAUDE$INSTALL_CODE_CODEX$INSTALL_CODE_VIBE" == *1* ]]; then
if [[ "$INSTALL_CODE_CLAUDE$INSTALL_CODE_OPENCODE$INSTALL_CODE_CODEX$INSTALL_CODE_VIBE" == *1* ]]; then
INSTALL_CODE_SERVER=1
fi
# MCP servers wired into every installed agent's config (see `agent-vm setup --help`).
Expand All @@ -62,8 +63,9 @@ INSTALL_MCP_PLAYWRIGHT="${AGENT_VM_INSTALL_MCP_PLAYWRIGHT:-0}"

# An agent there in either form: its command line and its extension read the
# same config, which is written for both.
HAS_CLAUDE=0 HAS_CODEX=0 HAS_VIBE=0
HAS_CLAUDE=0 HAS_OPENCODE=0 HAS_CODEX=0 HAS_VIBE=0
[[ "$INSTALL_CLAUDE$INSTALL_CODE_CLAUDE" == *1* ]] && HAS_CLAUDE=1
[[ "$INSTALL_OPENCODE$INSTALL_CODE_OPENCODE" == *1* ]] && HAS_OPENCODE=1
[[ "$INSTALL_CODEX$INSTALL_CODE_CODEX" == *1* ]] && HAS_CODEX=1
[[ "$INSTALL_VIBE$INSTALL_CODE_VIBE" == *1* ]] && HAS_VIBE=1

Expand Down Expand Up @@ -266,7 +268,7 @@ if [[ "$HAS_CLAUDE" == "1" ]]; then
JSON
fi

if [[ "$INSTALL_OPENCODE" == "1" ]]; then
if [[ "$INSTALL_OPENCODE" == "1" || "$INSTALL_CODE_OPENCODE" == "1" ]]; then
echo "Installing OpenCode..."
curl -fsSL https://opencode.ai/install | bash
echo 'export PATH=$HOME/.opencode/bin:$PATH' >> ~/.zshenv
Expand Down Expand Up @@ -425,6 +427,7 @@ if [[ "$INSTALL_CODE_SERVER" == "1" ]]; then
curl -fsSL https://code-server.dev/install.sh | sh
extensions=()
[[ "$INSTALL_CODE_CLAUDE" == "1" ]] && extensions+=(--install-extension anthropic.claude-code)
[[ "$INSTALL_CODE_OPENCODE" == "1" ]] && extensions+=(--install-extension sst-dev.opencode)
[[ "$INSTALL_CODE_CODEX" == "1" ]] && extensions+=(--install-extension openai.chatgpt)
[[ "$INSTALL_CODE_VIBE" == "1" ]] && extensions+=(--install-extension mistralai.mistral-vibe-code)
if [[ ${#extensions[@]} -gt 0 ]]; then
Expand Down Expand Up @@ -537,7 +540,7 @@ configure_mcp() {
fi
fi

if [[ "$INSTALL_OPENCODE" == "1" ]]; then
if [[ "$HAS_OPENCODE" == "1" ]]; then
echo "Configuring $name MCP server for OpenCode..."
mkdir -p "$HOME/.config/opencode"
local config="$HOME/.config/opencode/opencode.json"
Expand Down Expand Up @@ -587,7 +590,7 @@ configure_mcp() {
# True when at least one agent is installed: nothing to configure otherwise,
# and no reason to print a "skipping" notice either.
any_agent_installed() {
[[ "$HAS_CLAUDE" == "1" || "$INSTALL_OPENCODE" == "1" \
[[ "$HAS_CLAUDE" == "1" || "$HAS_OPENCODE" == "1" \
|| "$HAS_CODEX" == "1" || "$HAS_VIBE" == "1" ]]
}

Expand Down
29 changes: 21 additions & 8 deletions internal/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ Options:
python, node, ruby, rust, golang, docker, chromium,
gh, claude, opencode, codex, vibe, pi, mcp-chrome,
mcp-playwright, code-server, code-claude,
code-codex, code-vibe
code-opencode, code-codex, code-vibe
'code-server' is VS Code in the browser, for
'agent-vm code', with GitHub Copilot turned off.
'code-claude', 'code-codex' and 'code-vibe' add that
'code-claude', 'code-opencode', 'code-codex' and 'code-vibe' add that
agent's extension, and code-server with it. Each
extension brings its own copy of the agent: 'claude',
'codex' and 'vibe' are the command-line ones, for
'agent-vm claude' and the others.
'agent-vm claude' and the others (OpenCode's extension
requires its command-line tool, which is installed with it).
Selecting codex or pi also installs node (npm). So does
mcp-chrome when chromium and an agent are selected
(npx). mcp-playwright does not: list node yourself.
Expand All @@ -81,7 +82,7 @@ Options:
// The software of the base, as agent-vm.setup.sh names it
// (AGENT_VM_INSTALL_<NAME>).
var software = []string{"python", "node", "ruby", "rust", "golang", "docker", "chromium", "gh", "claude", "opencode", "codex", "vibe", "pi",
"code-server", "code-claude", "code-codex", "code-vibe", "mcp-chrome", "mcp-playwright"}
"code-server", "code-claude", "code-opencode", "code-codex", "code-vibe", "mcp-chrome", "mcp-playwright"}

// The default set. Opt-in: Ruby, Rust, Go, Pi (released several times a
// week), Playwright MCP (a second browser-driving server, whose tools cost
Expand All @@ -108,7 +109,7 @@ func (in install) nodeNeededBy() string {
return "Codex CLI requires npm"
case in["pi"]:
return "Pi requires npm"
case in["chromium"] && in["mcp-chrome"] && (in["claude"] || in["opencode"] || in["vibe"] || in["code-claude"] || in["code-codex"] || in["code-vibe"]):
case in["chromium"] && in["mcp-chrome"] && (in["claude"] || in["opencode"] || in["vibe"] || in["code-claude"] || in["code-opencode"] || in["code-codex"] || in["code-vibe"]):
return "Chrome DevTools MCP uses npx"
}
return ""
Expand Down Expand Up @@ -195,6 +196,9 @@ func (e *app) setupCmd(ctx context.Context, args []string) int {
case f == "code-claude" || f == "code-codex" || f == "code-vibe":
// An extension needs the editor.
in.set(f, "code-server")
case f == "code-opencode":
// OpenCode's extension requires both the editor and the OpenCode CLI.
in.set(f, "code-server", "opencode")
case contains(software, f):
in.set(f)
default:
Expand Down Expand Up @@ -343,7 +347,12 @@ func (e *app) askCodeExtensions(in install) {
return
}
var names []string
for _, a := range []struct{ key, name string }{{"claude", "Claude Code"}, {"codex", "Codex"}, {"vibe", "Mistral Vibe"}} {
for _, a := range []struct{ key, name string }{
{"claude", "Claude Code"},
{"opencode", "OpenCode"},
{"codex", "Codex"},
{"vibe", "Mistral Vibe"},
} {
if in[a.key] {
names = append(names, a.name)
}
Expand All @@ -352,11 +361,15 @@ func (e *app) askCodeExtensions(in install) {
return
}
fmt.Fprintf(e.io.Stderr, " %s in the editor:\n", strings.Join(names, ", "))
choice := e.ui.AskChoice("Pick one", 1, "Extension and command line (agent-vm claude...)", "Extension only", "Command line only")
promptExtOnly := "Extension only"
if in["opencode"] {
promptExtOnly = "Extension only (OpenCode keeps the CLI, required by its extension)"
}
choice := e.ui.AskChoice("Pick one", 1, "Extension and command line (agent-vm claude...)", promptExtOnly, "Command line only")
if choice == 3 {
return
}
in["code-claude"], in["code-codex"], in["code-vibe"] = in["claude"], in["codex"], in["vibe"]
in["code-claude"], in["code-opencode"], in["code-codex"], in["code-vibe"] = in["claude"], in["opencode"], in["codex"], in["vibe"]
if choice == 2 {
in["claude"], in["codex"], in["vibe"] = false, false, false
}
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func on(m map[string]string) string {
return strings.Join(out, ",")
}

// TestSetupPreinstall verifies --preinstall flag combinations.
func TestSetupPreinstall(t *testing.T) {
for _, c := range []struct {
args []string
Expand All @@ -80,6 +81,7 @@ func TestSetupPreinstall(t *testing.T) {
{[]string{"--preinstall=none"}, ""},
{[]string{"--preinstall=all"}, strings.Join(software, ",")},
{[]string{"--preinstall=python, docker,claude"}, "python,docker,claude"},
{[]string{"--preinstall=code-opencode"}, "opencode,code-server,code-opencode"},
{[]string{"--preinstall=code-vibe"}, "code-server,code-vibe"},
// Codex needs npm: node goes in, and it is said.
{[]string{"--preinstall=codex"}, "node,codex"},
Expand Down Expand Up @@ -132,6 +134,7 @@ func wizardRun(t *testing.T, answers ...string) (*testEnv, []string) {
return te, stdin
}

// TestSetupWizard verifies interactive setup wizard choices and defaults.
func TestSetupWizard(t *testing.T) {
// Enter everywhere: the default set and resources.
te, stdin := wizardRun(t)
Expand Down Expand Up @@ -164,6 +167,22 @@ func TestSetupWizard(t *testing.T) {
strings.Contains(te.out(), " agent-vm claude\n") {
t.Errorf("output: %s", te.out())
}

// OpenCode as extension only (keeps the required OpenCode CLI)
te, stdin = wizardRun(t,
"n", // the default set
"n", "y", "n", "n", "", // Claude, OpenCode, Codex, Vibe, Pi
"y", "2", // code-server, extension only
"n", "n", "n", // Docker, Chromium, gh
"n", "n", "", "", "", // Python, Node, Ruby, Rust, Go
"", // resources
)
if got := on(exportsOf(t, stdin)); got != "opencode,code-server,code-opencode" {
t.Errorf("opencode extension: %s", got)
}
if !strings.Contains(te.out(), "Extension only (OpenCode keeps the CLI, required by its extension)") {
t.Errorf("prompt not shown: %s", te.out())
}
}

// code-server makes the default memory 4, but --memory wins. No terminal:
Expand Down
25 changes: 13 additions & 12 deletions tests/setup-script.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,13 @@ check "sshfs wrapper: an older sshfs gets the arguments unchanged" \
# An agent is there as a command, as an extension, or both: either way its
# config (managed settings, MCP servers) is written. The lines setup derives
# it with, run as setup does.
has_block="$(awk '/^HAS_CLAUDE=0 HAS_CODEX=0 HAS_VIBE=0$/,/HAS_VIBE=1$/' "$SETUP_SH")"
has_block="$(awk '/^HAS_CLAUDE=0 HAS_OPENCODE=0 HAS_CODEX=0 HAS_VIBE=0$/,/HAS_VIBE=1$/' "$SETUP_SH")"
# has derives the HAS_* flags for the given set of agent install flags.
# shellcheck disable=SC2034 # read by the eval'd block
has() { ( INSTALL_CLAUDE="$1" INSTALL_CODE_CLAUDE="$2" INSTALL_CODEX="$3" INSTALL_CODE_CODEX="$4" INSTALL_VIBE="$5" INSTALL_CODE_VIBE="$6"
eval "$has_block"; echo "$HAS_CLAUDE$HAS_CODEX$HAS_VIBE" ); }
has() { ( INSTALL_CLAUDE="$1" INSTALL_CODE_CLAUDE="$2" INSTALL_OPENCODE="$3" INSTALL_CODE_OPENCODE="$4" INSTALL_CODEX="$5" INSTALL_CODE_CODEX="$6" INSTALL_VIBE="$7" INSTALL_CODE_VIBE="$8"
eval "$has_block"; echo "$HAS_CLAUDE$HAS_OPENCODE$HAS_CODEX$HAS_VIBE" ); }
check "setup: the agents' config, for a command or an extension" \
"$(has 1 0 0 0 0 0) $(has 0 1 0 0 0 0) $(has 0 0 0 1 0 0) $(has 0 0 0 0 1 1) $(has 0 0 0 0 0 0)" "100 100 010 001 000"
"$(has 1 0 0 0 0 0 0 0) $(has 0 1 0 0 0 0 0 0) $(has 0 0 1 0 0 0 0 0) $(has 0 0 0 1 0 0 0 0) $(has 0 0 0 0 1 0 0 0) $(has 0 0 0 0 0 1 0 0) $(has 0 0 0 0 0 0 1 1) $(has 0 0 0 0 0 0 0 0)" "1000 1000 0100 0100 0010 0010 0001 0000"

# =============================================================================
section "MCP config writer"
Expand All @@ -128,7 +129,7 @@ else
(
HOME="$MCPHOME"
eval "$(awk '/^configure_mcp\(\) \{/,/^\}/' "$SETUP_SH")"
HAS_CLAUDE=1 INSTALL_OPENCODE=1 HAS_VIBE=1 HAS_CODEX=1
HAS_CLAUDE=1 HAS_OPENCODE=1 HAS_VIBE=1 HAS_CODEX=1
for _ in 1 2; do # twice: the writer must be idempotent
configure_mcp chrome-devtools npx -y chrome-devtools-mcp@latest --headless=true
configure_mcp playwright env PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npx -y @playwright/mcp@latest
Expand Down Expand Up @@ -253,16 +254,16 @@ else
cs_block="$(awk '/^toml_prepend\(\) \{/,/^\}/' "$SETUP_SH")
$(awk '/^if \[\[ "\$INSTALL_CODE_SERVER" == "1" \]\]; then/,/^fi$/' "$SETUP_SH")
$(awk '/^configure_mcp\(\) \{/,/^\}/' "$SETUP_SH")"
# <home> <claude> <codex> <vibe>: the extensions asked for.
# <home> <claude> <opencode> <codex> <vibe>: the extensions asked for.
# Under set -e, as the script runs: a failing step must show here. Twice:
# what it writes must not pile up when it runs again in the same VM. The
# first run's state is kept in <home>/first.
# shellcheck disable=SC2034 # read by the eval'd block
run_cs_block() {
mkdir -p "$1/.config/code-server"; echo "password: from-the-base" > "$1/.config/code-server/config.yaml"
( set -e
HOME="$1"; INSTALL_CODE_SERVER=1 INSTALL_CODE_CLAUDE="$2" INSTALL_CODE_CODEX="$3" INSTALL_CODE_VIBE="$4"
HAS_CLAUDE="$2" HAS_CODEX="$3" HAS_VIBE="$4" INSTALL_OPENCODE=0
HOME="$1"; INSTALL_CODE_SERVER=1 INSTALL_CODE_CLAUDE="$2" INSTALL_CODE_OPENCODE="$3" INSTALL_CODE_CODEX="$4" INSTALL_CODE_VIBE="$5"
HAS_CLAUDE="$2" HAS_OPENCODE="$3" HAS_CODEX="$4" HAS_VIBE="$5"
curl() { echo "curl $*" >> "$HOME/calls.log"; }
code-server() { echo "code-server $*" >> "$HOME/calls.log"; }
code_server_schemas() { echo "schemas $*" >> "$HOME/calls.log"; mkdir -p "$(dirname "$2")"; echo '{"json.schemas":[]}' > "$2"; }
Expand All @@ -275,7 +276,7 @@ $(awk '/^configure_mcp\(\) \{/,/^\}/' "$SETUP_SH")"
echo 'done' > "$HOME/finished" ) >/dev/null 2>&1
}
CSB="$SB/cs-bare"; mkdir -p "$CSB"
run_cs_block "$CSB" 0 0 0
run_cs_block "$CSB" 0 0 0 0
check "code-server alone: no extension installed" "$(grep -c '^code-server' "$CSB/calls.log")" "0"
check "code-server alone: Copilot off" \
"$(jq -r '."chat.disableAIFeatures"' "$CSB/.local/share/code-server/User/settings.json")" "true"
Expand All @@ -297,14 +298,14 @@ $(awk '/^configure_mcp\(\) \{/,/^\}/' "$SETUP_SH")"
check "code-server alone: runs to the end under set -e" "$(cat "$CSB/finished" 2>/dev/null)" "done"

CSA="$SB/cs-all"; mkdir -p "$CSA"
run_cs_block "$CSA" 1 1 1
run_cs_block "$CSA" 1 1 1 1
check "with the extensions: runs to the end under set -e" "$(cat "$CSA/finished" 2>/dev/null)" "done"
check "codex: written on a fresh home" "$(head -n 2 "$CSA/first/.codex" 2>/dev/null | sort | tr '\n' ' ')" \
'approval_policy = "never" sandbox_mode = "danger-full-access" '
check "vibe: written on a fresh home" "$(cat "$CSA/first/.vibe" 2>/dev/null)" 'default_agent = "auto-approve"'
check "the three extensions, in one call" \
check "the four extensions, in one call" \
"$(grep '^code-server' "$CSA/calls.log" | head -n 1)" \
"code-server --install-extension anthropic.claude-code --install-extension openai.chatgpt --install-extension mistralai.mistral-vibe-code"
"code-server --install-extension anthropic.claude-code --install-extension sst-dev.opencode --install-extension openai.chatgpt --install-extension mistralai.mistral-vibe-code"
# The permission keys are machine-scoped: code-server reads them from the
# machine settings only.
check "claude: bypass mode allowed and picked, in the machine settings" \
Expand Down
4 changes: 2 additions & 2 deletions www/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -669,12 +669,12 @@ export const en = {
['MCP', 'Chrome DevTools MCP, wired into every installed agent but Pi (no MCP support)', 'mcp-chrome', 'yes'],
['MCP', 'Playwright MCP, reusing the same Chromium', 'mcp-playwright', 'no'],
['Editor', 'code-server (VS Code in the browser, for `agent-vm code`), dark theme, GitHub Copilot off, no telemetry', 'code-server', 'no'],
['Editor', 'The Claude Code, Codex or Mistral Vibe extension, each starting with no permission prompts, with code-server', 'code-claude, code-codex, code-vibe', 'no'],
['Editor', 'The Claude Code, OpenCode, Codex or Mistral Vibe extension, each starting with no permission prompts, with code-server', 'code-claude, code-opencode, code-codex, code-vibe', 'no'],
],
contentsCode:
'agent-vm setup --preinstall=default # no prompts\nagent-vm setup --preinstall=default,rust # plus Rust\nagent-vm setup --preinstall=python,docker,claude # minimal Claude setup\nagent-vm setup --preinstall=default,code-claude # plus the editor\nagent-vm setup --disk 50 --memory 16 --cpus 8 # a bigger template',
contentsNote:
'`codex` and `pi` pull in `node`, and so does `mcp-chrome` with `chromium` and an agent. Both MCP servers are skipped without `node` and `chromium`. Each editor extension ships its own copy of its agent (200 to 600 MB): the wizard asks whether to keep the command-line one too. Without it, `agent-vm claude` says it is not installed. This is only what the template ships: the agent is root in the VM and reaches the internet, so it installs whatever else it needs.',
'`codex` and `pi` pull in `node`, and so does `mcp-chrome` with `chromium` and an agent. Both MCP servers are skipped without `node` and `chromium`. Each editor extension ships its own copy of its agent (200 to 600 MB): the wizard asks whether to keep the command-line one too (OpenCode’s extension requires the CLI, which is always installed with it). Without it, `agent-vm claude` says it is not installed. This is only what the template ships: the agent is root in the VM and reaches the internet, so it installs whatever else it needs.',
dockerTitle: 'Why not Docker',
dockerLede:
'On Linux, containers share the host kernel, so a compromised dependency that finds a kernel bug is on your host. On macOS, Docker Desktop already runs a VM, which narrows the gap, but it is one VM shared by every container rather than one per project. A VM brings its own kernel, and root inside it still has the hypervisor between it and your machine. The practical differences matter as much as the security one.',
Expand Down
4 changes: 2 additions & 2 deletions www/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,12 +671,12 @@ export const fr: Dictionary = {
['MCP', 'Chrome DevTools MCP, câblé dans chaque agent installé sauf Pi (pas de support MCP)', 'mcp-chrome', 'oui'],
['MCP', 'Playwright MCP, réutilisant le même Chromium', 'mcp-playwright', 'non'],
['Éditeur', 'code-server (VS Code dans le navigateur, pour `agent-vm code`), thème sombre, GitHub Copilot désactivé, sans télémétrie', 'code-server', 'non'],
['Éditeur', 'L\'extension Claude Code, Codex ou Mistral Vibe, chacune démarrant sans demande de permission, avec code-server', 'code-claude, code-codex, code-vibe', 'non'],
['Éditeur', 'L\'extension Claude Code, OpenCode, Codex ou Mistral Vibe, chacune démarrant sans demande de permission, avec code-server', 'code-claude, code-opencode, code-codex, code-vibe', 'non'],
],
contentsCode:
'agent-vm setup --preinstall=default # sans question\nagent-vm setup --preinstall=default,rust # avec Rust en plus\nagent-vm setup --preinstall=python,docker,claude # Claude seul, minimal\nagent-vm setup --preinstall=default,code-claude # avec l\'éditeur\nagent-vm setup --disk 50 --memory 16 --cpus 8 # une image plus grosse',
contentsNote:
'`codex` et `pi` entraînent `node`, tout comme `mcp-chrome` avec `chromium` et un agent. Les deux serveurs MCP sont ignorés sans `node` et `chromium`. Chaque extension de l\'éditeur apporte sa propre copie de son agent (200 à 600 Mo) : l\'assistant demande s\'il faut garder aussi celle en ligne de commande. Sans elle, `agent-vm claude` dit qu\'il n\'est pas installé. Ce n\'est que ce que l\'image contient au départ : l\'agent est root dans la VM et atteint Internet, il installe lui-même ce qui lui manque.',
'`codex` et `pi` entraînent `node`, tout comme `mcp-chrome` avec `chromium` et un agent. Les deux serveurs MCP sont ignorés sans `node` et `chromium`. Chaque extension de l\'éditeur apporte sa propre copie de son agent (200 à 600 Mo) : l\'assistant demande s\'il faut garder aussi celle en ligne de commande (l\'extension d\'OpenCode nécessite l\'outil en ligne de commande, qui est toujours installé avec elle). Sans elle, `agent-vm claude` dit qu\'il n\'est pas installé. Ce n\'est que ce que l\'image contient au départ : l\'agent est root dans la VM et atteint Internet, il installe lui-même ce qui lui manque.',
dockerTitle: 'Pourquoi pas Docker',
dockerLede:
'Sous Linux, les conteneurs partagent le noyau de l\'hôte : une dépendance compromise qui trouve une faille dans le noyau se retrouve sur ta machine. Sous macOS, Docker Desktop fait déjà tourner une VM, ce qui réduit l\'écart, mais c\'est une seule VM partagée par tous tes conteneurs, pas une par projet. Une VM a son propre noyau, et root dans la VM a encore l\'hyperviseur entre lui et ton poste. Les différences pratiques comptent autant que celle-ci.',
Expand Down