feat: Add shared_network option for container workspaces

Add a new `shared_network` option to workspaces and workspace templates
that controls network configuration for container (nspawn) workspaces:

- When true (default): Share host network, bind-mount /etc/resolv.conf
  for DNS resolution
- When false: Use isolated networking (--network-veth) for Tailscale
  or other custom network configurations

This fixes DNS resolution issues in container workspaces that don't use
Tailscale by properly sharing the host's DNS configuration.

Changes:
- Add shared_network field to Workspace and WorkspaceTemplate structs
- Update nspawn command building to use shared_network setting
- Add UI toggles in workspace template editor and workspace settings
- Update API types and endpoints
This commit is contained in:
Thomas Marchand
2026-01-17 15:01:03 +00:00
parent e5c0427c14
commit 5c2f898234
9 changed files with 232 additions and 27 deletions

View File

@@ -52,6 +52,7 @@ const buildSnapshot = (data: {
skills: string[];
envRows: EnvRow[];
initScript: string;
sharedNetwork: boolean | null;
}) =>
JSON.stringify({
description: data.description,
@@ -59,6 +60,7 @@ const buildSnapshot = (data: {
skills: data.skills,
env: data.envRows.map((row) => ({ key: row.key, value: row.value, encrypted: row.encrypted })),
initScript: data.initScript,
sharedNetwork: data.sharedNetwork,
});
export default function WorkspaceTemplatesPage() {
@@ -94,6 +96,7 @@ export default function WorkspaceTemplatesPage() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [envRows, setEnvRows] = useState<EnvRow[]>([]);
const [initScript, setInitScript] = useState('');
const [sharedNetwork, setSharedNetwork] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
@@ -118,8 +121,9 @@ export default function WorkspaceTemplatesPage() {
skills: selectedSkills,
envRows,
initScript,
sharedNetwork,
}),
[description, distro, selectedSkills, envRows, initScript]
[description, distro, selectedSkills, envRows, initScript, sharedNetwork]
);
useEffect(() => {
@@ -186,12 +190,14 @@ export default function WorkspaceTemplatesPage() {
const rows = toEnvRows(template.env_vars || {}, template.encrypted_keys);
setEnvRows(rows);
setInitScript(template.init_script || '');
setSharedNetwork(template.shared_network ?? null);
baselineRef.current = buildSnapshot({
description: template.description || '',
distro: template.distro || '',
skills: template.skills || [],
envRows: rows,
initScript: template.init_script || '',
sharedNetwork: template.shared_network ?? null,
});
setDirty(false);
} catch (err) {
@@ -210,6 +216,7 @@ export default function WorkspaceTemplatesPage() {
env_vars: envRowsToMap(envRows),
encrypted_keys: getEncryptedKeys(envRows),
init_script: initScript,
shared_network: sharedNetwork,
});
baselineRef.current = snapshot;
setDirty(false);
@@ -257,6 +264,7 @@ export default function WorkspaceTemplatesPage() {
setSelectedSkills([]);
setEnvRows([]);
setInitScript('');
setSharedNetwork(null);
setDirty(false);
await loadTemplates();
} catch (err) {
@@ -592,6 +600,51 @@ export default function WorkspaceTemplatesPage() {
))}
</select>
</div>
<div className="rounded-xl bg-white/[0.02] border border-white/[0.05] p-4">
<div className="flex items-center justify-between">
<div>
<label className="text-xs text-white/40 block mb-1">Shared Network</label>
<p className="text-[10px] text-white/25">
Share host network and DNS. Disable for isolated networking (e.g., Tailscale).
</p>
</div>
<button
onClick={() => {
// Toggle: null (default=true) -> false -> true -> null
if (sharedNetwork === null) setSharedNetwork(false);
else if (sharedNetwork === false) setSharedNetwork(true);
else setSharedNetwork(null);
}}
className={cn(
"relative w-11 h-6 rounded-full transition-colors",
sharedNetwork === null
? "bg-white/10" // default (true)
: sharedNetwork
? "bg-emerald-500/50"
: "bg-red-500/30"
)}
>
<span
className={cn(
"absolute top-1 w-4 h-4 rounded-full bg-white transition-all",
sharedNetwork === null
? "left-6" // default position (on)
: sharedNetwork
? "left-6"
: "left-1"
)}
/>
</button>
</div>
<p className="text-[10px] text-white/30 mt-2">
{sharedNetwork === null
? "Default (enabled)"
: sharedNetwork
? "Enabled"
: "Disabled (isolated)"}
</p>
</div>
</div>
)}

View File

@@ -84,6 +84,7 @@ export default function WorkspacesPage() {
// Workspace settings state
const [envRows, setEnvRows] = useState<EnvRow[]>([]);
const [initScript, setInitScript] = useState('');
const [sharedNetwork, setSharedNetwork] = useState<boolean | null>(null);
const [savingWorkspace, setSavingWorkspace] = useState(false);
const [savingTemplate, setSavingTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
@@ -162,6 +163,7 @@ export default function WorkspacesPage() {
}
setEnvRows(toEnvRows(selectedWorkspace.env_vars ?? {}));
setInitScript(selectedWorkspace.init_script ?? '');
setSharedNetwork(selectedWorkspace.shared_network ?? null);
setSelectedSkills(selectedWorkspace.skills ?? []);
setTemplateName(`${selectedWorkspace.name}-template`);
setTemplateDescription('');
@@ -341,6 +343,7 @@ export default function WorkspacesPage() {
env_vars,
init_script: initScript,
skills: selectedSkills,
shared_network: sharedNetwork,
});
setSelectedWorkspace(updated);
await mutateWorkspaces();
@@ -601,6 +604,51 @@ export default function WorkspacesPage() {
</div>
)}
{/* Network settings for chroot workspaces */}
{selectedWorkspace.workspace_type === 'chroot' && (
<div className="rounded-lg bg-white/[0.02] border border-white/[0.05] p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-white/60 font-medium">Shared Network</p>
<p className="text-[10px] text-white/30 mt-0.5">
Share host network and DNS. Disable for isolated networking.
</p>
</div>
<button
onClick={() => {
if (sharedNetwork === null) setSharedNetwork(false);
else if (sharedNetwork === false) setSharedNetwork(true);
else setSharedNetwork(null);
}}
className={cn(
"relative w-10 h-5 rounded-full transition-colors",
sharedNetwork === null
? "bg-white/10"
: sharedNetwork
? "bg-emerald-500/50"
: "bg-red-500/30"
)}
>
<span
className={cn(
"absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all",
sharedNetwork === null || sharedNetwork
? "left-5"
: "left-0.5"
)}
/>
</button>
</div>
<p className="text-[10px] text-white/25 mt-1.5">
{sharedNetwork === null
? "Default (enabled)"
: sharedNetwork
? "Enabled"
: "Disabled (isolated)"}
</p>
</div>
)}
{/* Action hint for chroot workspaces */}
{selectedWorkspace.workspace_type === 'chroot' && selectedWorkspace.status !== 'building' && selectedWorkspace.status !== 'ready' && (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/15 p-3">

View File

@@ -1812,6 +1812,7 @@ export interface WorkspaceTemplate {
env_vars: Record<string, string>;
encrypted_keys: string[];
init_script: string;
shared_network?: boolean | null;
}
export async function listWorkspaceTemplates(): Promise<WorkspaceTemplateSummary[]> {
@@ -1835,6 +1836,7 @@ export async function saveWorkspaceTemplate(
env_vars?: Record<string, string>;
encrypted_keys?: string[];
init_script?: string;
shared_network?: boolean | null;
}
): Promise<void> {
const res = await apiFetch(`/api/library/workspace-template/${encodeURIComponent(name)}`, {
@@ -1863,6 +1865,7 @@ export async function renameWorkspaceTemplate(oldName: string, newName: string):
env_vars: template.env_vars,
encrypted_keys: template.encrypted_keys,
init_script: template.init_script,
shared_network: template.shared_network,
});
// Delete old template
await deleteWorkspaceTemplate(oldName);
@@ -1951,6 +1954,7 @@ export interface Workspace {
distro?: string | null;
env_vars: Record<string, string>;
init_script?: string | null;
shared_network?: boolean | null;
}
// List workspaces
@@ -1978,6 +1982,7 @@ export async function createWorkspace(data: {
distro?: string;
env_vars?: Record<string, string>;
init_script?: string;
shared_network?: boolean | null;
}): Promise<Workspace> {
const res = await apiFetch("/api/workspaces", {
method: "POST",
@@ -1999,6 +2004,7 @@ export async function updateWorkspace(
distro?: string | null;
env_vars?: Record<string, string>;
init_script?: string | null;
shared_network?: boolean | null;
}
): Promise<Workspace> {
const res = await apiFetch(`/api/workspaces/${id}`, {

View File

@@ -43,12 +43,12 @@ nav.nextra-nav-container {
background: rgba(249, 250, 251, 0.5);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border-right: 1px solid rgba(0, 0, 0, 0.06);
border-right: 1px solid rgba(0, 0, 0, 0.1);
}
.dark .nextra-sidebar-container {
background: rgba(24, 23, 22, 0.5);
border-right: 1px solid rgba(255, 248, 240, 0.06);
border-right: 1px solid rgba(255, 248, 240, 0.1);
}
/* Sidebar ambient tint - blue */
@@ -861,36 +861,74 @@ aside a:hover,
}
/* ============================================
SIDEBAR BOTTOM-LEFT CORNER FIX
SIDEBAR FOOTER SEPARATOR
============================================ */
/* Fix the rounded corner artifact in the bottom-left of the sidebar */
/* Add top border to sidebar footer section (theme toggle, collapse button area) */
.nextra-sidebar-container > div:last-child,
aside.nextra-sidebar-container > div:last-child {
border-top: 1px solid rgba(0, 0, 0, 0.06);
margin-top: auto;
padding-top: 0.75rem;
}
.dark .nextra-sidebar-container > div:last-child,
.dark aside.nextra-sidebar-container > div:last-child {
border-top: 1px solid rgba(255, 248, 240, 0.06);
}
/* ============================================
SIDEBAR COLLAPSED STATE & CORNER FIXES
============================================ */
/* Remove all rounded corners from sidebar in any state */
.nextra-sidebar-container,
aside.nextra-sidebar-container,
[class*="sidebar-container"],
aside[class*="nextra"] {
border-radius: 0 !important;
}
/* Target the theme switcher container at the bottom of the sidebar */
.nextra-sidebar-container > div:last-child,
aside > div:last-child,
aside > div:last-of-type {
border-radius: 0 !important;
}
/* Ensure the sidebar footer area has no rounded corners */
[class*="sidebar"] [class*="footer"],
[class*="sidebar"] > :last-child,
aside [class*="theme"],
aside [class*="toggle"] {
border-radius: 0 !important;
}
/* Target any nested containers that might have rounded corners */
aside[class*="nextra"],
.nextra-sidebar-container *,
aside.nextra-sidebar-container * {
border-bottom-left-radius: 0 !important;
border-radius: 0 !important;
}
/* Fix collapsed sidebar - extend to full height, remove floating box effect */
.nextra-sidebar-container[data-collapsed="true"],
aside[data-collapsed="true"],
.nextra-sidebar-container:has(> div:only-child),
[class*="sidebar"][class*="collapsed"] {
background: rgba(249, 250, 251, 0.5) !important;
backdrop-filter: blur(12px) saturate(150%) !important;
-webkit-backdrop-filter: blur(12px) saturate(150%) !important;
border-right: 1px solid rgba(0, 0, 0, 0.1) !important;
height: 100% !important;
border-radius: 0 !important;
}
.dark .nextra-sidebar-container[data-collapsed="true"],
.dark aside[data-collapsed="true"],
.dark .nextra-sidebar-container:has(> div:only-child),
.dark [class*="sidebar"][class*="collapsed"] {
background: rgba(24, 23, 22, 0.5) !important;
border-right: 1px solid rgba(255, 248, 240, 0.1) !important;
}
/* Remove background/border from inner containers in collapsed state to prevent floating box */
.nextra-sidebar-container > div,
aside.nextra-sidebar-container > div {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Keep the footer separator visible */
.nextra-sidebar-container > div:last-child,
aside.nextra-sidebar-container > div:last-child {
border-top: 1px solid rgba(0, 0, 0, 0.06) !important;
}
.dark .nextra-sidebar-container > div:last-child,
.dark aside.nextra-sidebar-container > div:last-child {
border-top: 1px solid rgba(255, 248, 240, 0.06) !important;
}
/* Specifically remove the bottom-left radius from the sidebar wrapper */

View File

@@ -294,6 +294,9 @@ pub struct SaveWorkspaceTemplateRequest {
pub env_vars: Option<HashMap<String, String>>,
pub encrypted_keys: Option<Vec<String>>,
pub init_script: Option<String>,
/// Whether to share the host network (default: true).
/// Set to false for isolated networking (e.g., Tailscale).
pub shared_network: Option<bool>,
}
#[derive(Debug, Deserialize)]
@@ -958,6 +961,7 @@ async fn save_workspace_template(
env_vars: req.env_vars.unwrap_or_default(),
encrypted_keys: req.encrypted_keys.unwrap_or_default(),
init_script: req.init_script.unwrap_or_default(),
shared_network: req.shared_network,
};
library

View File

@@ -69,6 +69,9 @@ pub struct CreateWorkspaceRequest {
pub env_vars: Option<HashMap<String, String>>,
/// Init script to run when the workspace is built/rebuilt
pub init_script: Option<String>,
/// Whether to share the host network (default: true).
/// Set to false for isolated networking (e.g., Tailscale).
pub shared_network: Option<bool>,
}
#[derive(Debug, Deserialize)]
@@ -89,6 +92,9 @@ pub struct UpdateWorkspaceRequest {
pub env_vars: Option<HashMap<String, String>>,
/// Init script to run when the workspace is built/rebuilt
pub init_script: Option<String>,
/// Whether to share the host network (default: true).
/// Set to false for isolated networking (e.g., Tailscale).
pub shared_network: Option<bool>,
}
#[derive(Debug, Serialize)]
@@ -107,6 +113,7 @@ pub struct WorkspaceResponse {
pub distro: Option<String>,
pub env_vars: HashMap<String, String>,
pub init_script: Option<String>,
pub shared_network: Option<bool>,
}
impl From<Workspace> for WorkspaceResponse {
@@ -126,6 +133,7 @@ impl From<Workspace> for WorkspaceResponse {
distro: w.distro,
env_vars: w.env_vars,
init_script: w.init_script,
shared_network: w.shared_network,
}
}
}
@@ -326,6 +334,11 @@ async fn create_workspace(
None => None,
};
// shared_network: request overrides template, default to true (None means true)
let shared_network = req
.shared_network
.or_else(|| template_data.as_ref().and_then(|t| t.shared_network));
let mut workspace = match workspace_type {
WorkspaceType::Host => Workspace {
id: Uuid::new_v4(),
@@ -343,6 +356,7 @@ async fn create_workspace(
skills,
tools: req.tools,
plugins: req.plugins,
shared_network,
},
WorkspaceType::Chroot => {
let mut ws = Workspace::new_chroot(req.name, path);
@@ -353,6 +367,7 @@ async fn create_workspace(
ws.distro = distro;
ws.env_vars = env_vars;
ws.init_script = init_script;
ws.shared_network = shared_network;
ws
}
};
@@ -520,6 +535,10 @@ async fn update_workspace(
workspace.init_script = normalize_init_script(Some(init_script));
}
if let Some(shared_network) = req.shared_network {
workspace.shared_network = Some(shared_network);
}
// Save the updated workspace
state.workspaces.update(workspace.clone()).await;

View File

@@ -42,6 +42,9 @@ struct WorkspaceTemplateConfig {
encrypted_keys: Vec<String>,
#[serde(default)]
init_script: String,
/// Whether to share the host network (default: true).
#[serde(default)]
shared_network: Option<bool>,
}
// Directory constants (OpenCode-aligned structure)
@@ -1275,6 +1278,7 @@ impl LibraryStore {
env_vars,
encrypted_keys,
init_script: config.init_script,
shared_network: config.shared_network,
})
}
@@ -1329,6 +1333,7 @@ impl LibraryStore {
env_vars,
encrypted_keys: template.encrypted_keys.clone(),
init_script: template.init_script.clone(),
shared_network: template.shared_network,
};
let content = serde_json::to_string_pretty(&config)?;

View File

@@ -220,6 +220,11 @@ pub struct WorkspaceTemplate {
/// Init script to run on build
#[serde(default)]
pub init_script: String,
/// Whether to share the host network (default: true).
/// When true, bind-mounts /etc/resolv.conf for DNS.
/// Set to false for isolated networking (e.g., Tailscale).
#[serde(default)]
pub shared_network: Option<bool>,
}
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -119,6 +119,11 @@ pub struct Workspace {
/// Plugin identifiers for hooks
#[serde(default)]
pub plugins: Vec<String>,
/// Whether to share the host network (default: true).
/// When true, bind-mounts /etc/resolv.conf for DNS.
/// Set to false for isolated networking (e.g., Tailscale).
#[serde(default)]
pub shared_network: Option<bool>,
}
impl Workspace {
@@ -140,6 +145,7 @@ impl Workspace {
skills: Vec::new(),
tools: Vec::new(),
plugins: Vec::new(),
shared_network: None,
}
}
@@ -161,6 +167,7 @@ impl Workspace {
skills: Vec::new(),
tools: Vec::new(),
plugins: Vec::new(),
shared_network: None,
}
}
}
@@ -348,6 +355,7 @@ impl WorkspaceStore {
skills: Vec::new(),
tools: Vec::new(),
plugins: Vec::new(),
shared_network: None, // Default to shared network
};
orphaned.push(workspace);
@@ -509,6 +517,7 @@ fn opencode_entry_from_mcp(
workspace_root: &Path,
workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>,
shared_network: Option<bool>,
) -> serde_json::Value {
fn resolve_command_path(cmd: &str) -> String {
let cmd_path = Path::new(cmd);
@@ -638,7 +647,18 @@ fn opencode_entry_from_mcp(
context_dir_name,
);
}
cmd.extend(nspawn::tailscale_nspawn_extra_args(&merged_env));
// Network configuration based on shared_network setting:
// - shared_network=true (default): Share host network, bind-mount /etc/resolv.conf for DNS
// - shared_network=false: Isolated network (--network-veth), used with Tailscale
let use_shared_network = shared_network.unwrap_or(true);
if use_shared_network {
// Bind-mount host's resolv.conf for DNS resolution in shared network mode
cmd.push("--bind-ro=/etc/resolv.conf".to_string());
} else {
// Isolated network mode - use Tailscale network configuration
cmd.extend(nspawn::tailscale_nspawn_extra_args(&merged_env));
}
for (key, value) in &nspawn_env {
cmd.push(format!("--setenv={}={}", key, value));
}
@@ -666,6 +686,7 @@ async fn write_opencode_config(
workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>,
skill_allowlist: Option<&[String]>,
shared_network: Option<bool>,
) -> anyhow::Result<()> {
let mut mcp_map = serde_json::Map::new();
let mut used = std::collections::HashSet::new();
@@ -688,6 +709,7 @@ async fn write_opencode_config(
workspace_root,
workspace_type,
workspace_env,
shared_network,
),
);
}
@@ -1243,6 +1265,7 @@ pub async fn prepare_custom_workspace(
WorkspaceType::Host,
&workspace_env,
None,
None, // shared_network: not relevant for host workspaces
)
.await?;
Ok(workspace_dir)
@@ -1279,6 +1302,7 @@ pub async fn prepare_mission_workspace_in(
workspace.workspace_type,
&workspace.env_vars,
skill_allowlist,
workspace.shared_network,
)
.await?;
Ok(dir)
@@ -1307,6 +1331,7 @@ pub async fn prepare_mission_workspace_with_skills(
workspace.workspace_type,
&workspace.env_vars,
skill_allowlist,
workspace.shared_network,
)
.await?;
@@ -1407,6 +1432,7 @@ pub async fn prepare_task_workspace(
WorkspaceType::Host,
&workspace_env,
None,
None, // shared_network: not relevant for host workspaces
)
.await?;
Ok(dir)
@@ -1571,6 +1597,7 @@ pub async fn sync_all_workspaces(config: &Config, mcp: &McpRegistry) -> anyhow::
WorkspaceType::Host,
&workspace_env,
None,
None, // shared_network: not relevant for host workspaces
)
.await
.is_ok()