feat: add live config profile refresh

- Standardize built-in tool users for shared writable profile mounts
- Mount canonical non-Git profile sources across compatible instances
- Report restart-required outcomes and guard active profile deletion
- Surface restart feedback in config profile editing

Quality gates: frontend build passed; backend py_compile and LSP passed.
Skipped: backend pytest/Ruff unavailable; Docker/manual checks not approved.
This commit is contained in:
Developer
2026-07-21 11:12:41 +00:00
parent f9f9372ee7
commit add7c1b500
19 changed files with 546 additions and 160 deletions
+42 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models import ConfigProfile, ConfigProfileInclude, UserConfig
from src.models import ConfigProfile, ConfigProfileInclude, ToolInstance, UserConfig
from src.schemas.config import (
ConfigProfileCreate,
ConfigProfileIncludeUpdate,
@@ -43,6 +43,34 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
async def _running_profile_outcomes(
session: AsyncSession, profile_id: uuid.UUID
) -> list[dict[str, str]]:
"""Report running instances that must restart to adopt a profile revision."""
result = await session.execute(
select(ToolInstance).where(ToolInstance.status == "running")
)
outcomes: list[dict[str, str]] = []
for instance in result.scalars().all():
if instance.selected_config_profile_id is None:
continue
resolved = await resolve_profile(session, instance.selected_config_profile_id)
dependencies = {resolved.profile_id} | {
uuid.UUID(item["id"])
for item in resolved.included_profiles
if item.get("id")
}
if profile_id in dependencies:
outcomes.append(
{
"instance_id": str(instance.id),
"status": "restart_required",
"reason": "Existing instance must restart to adopt shared profile mounts",
}
)
return outcomes
@router.get("", response_model=list[ConfigProfileResponse])
async def list_config_profiles(
project_id: str | None = Query(None, description="Filter by project compatibility"),
@@ -140,8 +168,10 @@ async def update_config_profile(
)
profile = await update_profile(session, profile, data)
response = profile_to_response(profile)
response["refresh_outcomes"] = await _running_profile_outcomes(session, profile.id)
logger.debug("Updated config profile %s", profile.id)
return profile_to_response(profile)
return response
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -161,6 +191,16 @@ async def delete_config_profile(
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
)
outcomes = await _running_profile_outcomes(session, profile.id)
if outcomes:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Profile is still used by running instances",
"outcomes": outcomes,
},
)
await session.delete(profile)
await session.commit()
@@ -240,6 +240,12 @@ class ConfigProfileIncludeUpdate(BaseModel):
return v
class ConfigProfileRefreshOutcome(BaseModel):
instance_id: str
status: str
reason: str | None = None
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
@@ -256,6 +262,7 @@ class ConfigProfileResponse(BaseModel):
includes: list[dict]
created_at: str
updated_at: str
refresh_outcomes: list[ConfigProfileRefreshOutcome] = Field(default_factory=list)
class DefaultProfilesUpdate(BaseModel):
+88 -52
View File
@@ -5,47 +5,17 @@ import logging
from sqlalchemy import select, text
from src.database import SessionLocal
from src.models import ToolType
from src.models import ToolDefinitionManifest, ToolType
logger = logging.getLogger(__name__)
# All supported built-in tools run their long-lived process with these IDs.
# Canonical writable Config Profile mounts can therefore be shared without
# per-instance ownership changes.
BUILTIN_USER_UID = 1000
BUILTIN_USER_GID = 1000
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
builtin_types = [
BUILTIN_TOOL_TYPES = [
{
"name": "code-server",
"display_name": "VS Code Server",
@@ -83,6 +53,8 @@ services:
container_name: {{TOOL_NAME}}
environment:
- JUPYTER_ENABLE_LAB=yes
- NB_UID=1000
- NB_GID=1000
volumes:
- {{REPO_PATH}}:/home/jovyan/work
ports:
@@ -102,31 +74,95 @@ services:
opencode:
image: node:20-slim
container_name: {{TOOL_NAME}}
working_dir: /home/user/{{WORKSPACE_NAME}}
working_dir: /home/node/{{WORKSPACE_NAME}}
volumes:
- {{REPO_PATH}}:/home/user/{{WORKSPACE_NAME}}
- {{REPO_PATH}}:/home/node/{{WORKSPACE_NAME}}
ports:
- "3000:3000"
command: >
sh -c "set -x &&
apt-get update && apt-get install -y git ca-certificates &&
echo 'Installing opencode...' &&
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
which opencode || echo 'ERROR: opencode not in PATH' &&
npm bin -g &&
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
echo 'cd /home/user/{{WORKSPACE_NAME}}' >> /root/.bashrc &&
echo 'OpenCode installation complete' &&
exec tail -f /dev/null"
sh -ec "apt-get update && apt-get install -y git ca-certificates &&
npm install -g opencode-ai &&
exec setpriv --reuid=node --regid=node --init-groups opencode server"
stdin_open: true
tty: true
restart: 'no'""",
"required_variables": ["REPO_PATH", "TOOL_NAME"],
},
]
]
for tool_data in builtin_types:
def _standardize_builtin_manifest_user(manifest: dict) -> bool:
"""Set the built-in manifest user to the shared UID/GID in place.
The helper deliberately recognizes only Headquarter's conventional
``user`` account so it cannot rewrite a future custom tool definition.
"""
user = manifest.get("user")
if not isinstance(user, dict) or user.get("name") != "user":
return False
changed = user.get("uid") != BUILTIN_USER_UID or user.get("gid") != BUILTIN_USER_GID
if changed:
user["uid"] = BUILTIN_USER_UID
user["gid"] = BUILTIN_USER_GID
return changed
async def _standardize_pi_agent_manifest(session) -> None:
"""Bring the built-in Pi Agent manifest in line with shared mount IDs."""
manifest_definition = await session.scalar(
select(ToolDefinitionManifest).where(
ToolDefinitionManifest.name == "pi-agent",
ToolDefinitionManifest.created_by_id.is_(None),
)
)
if manifest_definition is None:
return
manifest = dict(manifest_definition.manifest)
if _standardize_builtin_manifest_user(manifest):
manifest_definition.manifest = manifest
logger.info("Standardized built-in Pi Agent user to 1000:1000")
async def _table_exists(session, table_name: str) -> bool:
"""Check if a table exists in the database."""
try:
result = await session.execute(
text(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar() or False
except Exception:
return False
async def seed_builtin_tool_types():
"""Create or update built-in tool types in the database.
Built-in tool types have no creator (created_by_id=None) and provide
out-of-the-box tools for users without requiring manual tool creation.
"""
async with SessionLocal() as session:
# Check if tool_types table exists before attempting to seed
if not await _table_exists(session, "tool_types"):
logger.warning(
"tool_types table does not exist. Skipping seeding. "
"Migrations may not have run yet."
)
return
await _standardize_pi_agent_manifest(session)
for tool_data in BUILTIN_TOOL_TYPES:
existing = await session.scalar(
select(ToolType).where(ToolType.name == tool_data["name"])
)
@@ -6,6 +6,7 @@ and cycle protection.
import logging
import os
import tempfile
import uuid
from dataclasses import dataclass, field
from typing import Any
@@ -481,6 +482,7 @@ def apply_resolved_profile(
instance_dir: str,
resolved: ResolvedProfile,
home_dir: str = "/root",
working_dir: str | None = None,
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
"""Apply a resolved profile to an instance directory.
@@ -489,6 +491,8 @@ def apply_resolved_profile(
Args:
instance_dir: Path to the instance directory.
resolved: The resolved profile.
home_dir: Container home directory used for path expansion.
working_dir: Container working directory for top-level profile files.
Returns:
Tuple of (env_vars, files, volume_mounts, runtime_hints).
@@ -501,49 +505,57 @@ def apply_resolved_profile(
instance_path = Path(instance_dir)
env_vars = dict(resolved.env_vars)
files = dict(resolved.files)
working_dir = working_dir or home_dir
volume_mounts = []
# Write profile files to instance directory
for file_path, content in files.items():
full_path = instance_path / file_path
try:
full_path.resolve().relative_to(instance_path.resolve())
except ValueError:
logger.warning(
"Profile file path escapes instance directory: %s", file_path
)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
# Profile content belongs to the profile, not an individual tool instance.
# Keeping it beside the instance root gives every compatible instance the
# same host source while retaining the existing instance storage setting.
profile_dir = instance_path.parent / "config-profiles" / str(resolved.profile_id)
files_dir = profile_dir / "files"
mounts_dir = profile_dir / "mounts"
# Stage mount directories and prepare directory-level volume mounts.
# Each ResolvedMount targets a container directory; we stage all of its
# files under a single host directory and bind-mount that directory. This
# keeps the target directory writable by the container user, instead of
# having Docker create a root-owned parent directory when only individual
# files are mounted.
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
path = root / relative_path
try:
path.resolve().relative_to(root.resolve())
except ValueError:
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
return None
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False
) as temporary_file:
temporary_file.write(content)
temporary_path = Path(temporary_file.name)
temporary_path.replace(path)
return path
# Top-level profile files are individual bind mounts under the working
# directory. They therefore cannot mask the workspace directory itself.
for file_path, content in resolved.files.items():
canonical_file = write_canonical_file(files_dir, file_path, content)
if canonical_file is None:
continue
volume_mounts.append(
{
"source": str(canonical_file),
"target": os.path.normpath(os.path.join(working_dir, file_path)),
"type": "bind",
"readonly": False,
}
)
# Explicit profile mounts remain directory-level bind mounts, but use the
# same profile-scoped canonical source for every instance.
for mount in resolved.mounts.values():
if not mount.files:
continue
expanded_target = os.path.normpath(
expand_container_path(mount.target, home_dir)
)
mount_dir = (
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
)
mount_dir.mkdir(parents=True, exist_ok=True)
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
for file_path, content in mount.files.items():
full_path = mount_dir / file_path
try:
full_path.resolve().relative_to(mount_dir.resolve())
except ValueError:
logger.warning("Mount file path escapes mount directory: %s", file_path)
continue
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content)
write_canonical_file(mount_dir, file_path, content)
volume_mounts.append(
{
@@ -554,7 +566,9 @@ def apply_resolved_profile(
}
)
return env_vars, files, volume_mounts, resolved.runtime_hints
# Files are now mounted directly from canonical storage, not copied into
# the instance directory for write_config_files().
return env_vars, {}, volume_mounts, resolved.runtime_hints
def expand_container_path(path: str, home_dir: str) -> str:
+10 -5
View File
@@ -1402,17 +1402,22 @@ async def start_tool_instance(
resolved = await resolve_profile(
session, instance.selected_config_profile_id
)
# Profile working-directory hints determine where individual
# canonical profile files are bind-mounted at container creation.
profile_hints = resolved.runtime_hints
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
profile_env, profile_files, profile_mounts, profile_hints = (
apply_resolved_profile(instance_dir, resolved, home_dir)
apply_resolved_profile(
instance_dir, resolved, home_dir, working_directory
)
)
# Profile hints override the manifest/tool defaults, and git mounts
# need the final working directory to resolve relative target paths.
if profile_hints.get("start_command"):
start_command = profile_hints["start_command"]
if profile_hints.get("working_directory"):
working_directory = expand_container_path(
profile_hints["working_directory"], home_dir
)
if profile_hints.get("port_override"):
port_override = profile_hints["port_override"]
env_vars.update(profile_env)
@@ -0,0 +1,62 @@
"""Tests for the shared non-root user used by built-in tools."""
from pathlib import Path
from src.seeds.builtin_tool_types import (
BUILTIN_TOOL_TYPES,
BUILTIN_USER_GID,
BUILTIN_USER_UID,
_standardize_builtin_manifest_user,
)
def test_builtin_compose_templates_use_shared_runtime_ids() -> None:
"""Every legacy built-in Compose tool declares the shared UID/GID."""
templates = {
str(tool["name"]): str(tool["compose_template"]) for tool in BUILTIN_TOOL_TYPES
}
assert "- PUID=1000" in templates["code-server"]
assert "- PGID=1000" in templates["code-server"]
assert "- NB_UID=1000" in templates["jupyter-notebook"]
assert "- NB_GID=1000" in templates["jupyter-notebook"]
assert "setpriv --reuid=node --regid=node --init-groups" in templates["opencode"]
def test_only_builtin_user_manifest_is_standardized() -> None:
"""The startup migration cannot rewrite a future custom tool user."""
builtin_manifest = {"user": {"name": "user", "uid": 1001, "gid": 1001}}
custom_manifest = {"user": {"name": "custom", "uid": 2000, "gid": 2000}}
assert _standardize_builtin_manifest_user(builtin_manifest)
assert builtin_manifest["user"] == {
"name": "user",
"uid": BUILTIN_USER_UID,
"gid": BUILTIN_USER_GID,
}
assert not _standardize_builtin_manifest_user(custom_manifest)
assert custom_manifest["user"] == {"name": "custom", "uid": 2000, "gid": 2000}
def test_tool_image_templates_define_shared_ids() -> None:
"""Project-owned image templates explicitly create or map UID/GID 1000."""
root = Path(__file__).resolve().parents[4]
sources = {
name: (root / "tool-images" / name).read_text()
for name in (
"base.dockerfile",
"opencode.dockerfile",
"pi-agent.dockerfile",
"code-server.dockerfile",
"jupyter.dockerfile",
)
}
for name in ("base.dockerfile", "opencode.dockerfile", "pi-agent.dockerfile"):
assert "groupadd -g 1000 user" in sources[name]
assert "useradd -m -u 1000 -g 1000" in sources[name]
assert "PUID=1000" in sources["code-server.dockerfile"]
assert "PGID=1000" in sources["code-server.dockerfile"]
assert "NB_UID=1000" in sources["jupyter.dockerfile"]
assert "NB_GID=1000" in sources["jupyter.dockerfile"]
@@ -532,6 +532,54 @@ class TestApplyResolvedProfile:
assert Path(volumes[0]["source"]).name == "workspace_x_y"
assert (Path(volumes[0]["source"]) / "z.json").exists()
def test_top_level_files_use_profile_scoped_direct_bind_mounts(self, tmp_path) -> None:
"""Top-level files are shared safely without mounting over a workspace."""
profile_id = uuid.uuid4()
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
files={".tool/config.toml": "setting = true"},
)
instance_root = tmp_path / "instances"
_, files, volumes, _ = apply_resolved_profile(
str(instance_root / "instance-a"),
resolved,
working_dir="/workspace/project",
)
canonical_file = (
instance_root / "config-profiles" / str(profile_id) / "files" / ".tool" / "config.toml"
)
assert files == {}
assert volumes == [
{
"source": str(canonical_file),
"target": "/workspace/project/.tool/config.toml",
"type": "bind",
"readonly": False,
}
]
assert canonical_file.read_text() == "setting = true"
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
"""Different instance paths resolve a profile to one canonical source."""
profile_id = uuid.uuid4()
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={"config.ini": "x"})},
)
instance_root = tmp_path / "instances"
_, _, first_volumes, _ = apply_resolved_profile(str(instance_root / "instance-a"), resolved)
_, _, second_volumes, _ = apply_resolved_profile(str(instance_root / "instance-b"), resolved)
assert first_volumes[0]["source"] == second_volumes[0]["source"]
assert first_volumes[0]["source"] == str(
instance_root / "config-profiles" / str(profile_id) / "mounts" / "app"
)
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
"""A mount with no files should not produce any volume entries."""
resolved = ResolvedProfile(
@@ -579,7 +627,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1
assert volumes[0]["target"] == "/etc/app"
assert volumes[0].get("readonly") is True
assert volumes[0].get("readonly")
def test_writable_mount_does_not_set_readonly_flag(self, tmp_path) -> None:
"""A mount with mode 'rw' should not set readonly on the volume entry."""
@@ -598,7 +646,7 @@ class TestApplyResolvedProfile:
assert len(volumes) == 1
assert volumes[0]["target"] == "/app"
assert volumes[0].get("readonly") is False
assert not volumes[0].get("readonly")
class TestCheckIncludeCycle:
+7
View File
@@ -1,5 +1,11 @@
import { apiClient } from "./client";
export interface ConfigProfileRefreshOutcome {
instance_id: string;
status: "compatible" | "restart_required" | "incompatible_permissions";
reason?: string;
}
export interface ConfigProfile {
id: string;
user_id: string;
@@ -16,6 +22,7 @@ export interface ConfigProfile {
includes: ConfigProfileInclude[];
created_at: string;
updated_at: string;
refresh_outcomes?: ConfigProfileRefreshOutcome[];
}
export interface ConfigProfileMount {
+46 -14
View File
@@ -34,17 +34,21 @@ export const useConfigProfiles = () => {
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [isCreating, setIsCreating] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [previewData, setPreviewData] = useState<ResolvedProfile | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateConfigProfileRequest>(defaultForm);
const [formData, setFormData] =
useState<CreateConfigProfileRequest>(defaultForm);
const [includedProfileIds, setIncludedProfileIds] = useState<string[]>([]);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) || null;
const selectedProfile =
profiles.find((p) => p.id === selectedProfileId) || null;
const loadData = useCallback(async () => {
setStatus("loading");
@@ -89,7 +93,9 @@ export const useConfigProfiles = () => {
is_default: profile.is_default,
});
setIncludedProfileIds(
profile.includes.map((inc: { included_profile_id: string }) => inc.included_profile_id),
profile.includes.map(
(inc: { included_profile_id: string }) => inc.included_profile_id,
),
);
setError(null);
setSaveStatus("idle");
@@ -208,20 +214,39 @@ export const useConfigProfiles = () => {
if (isCreating) {
const newProfile = await createConfigProfile(formData);
if (includedProfileIds.length > 0) {
await updateProfileIncludes(newProfile.id, { includes: includedProfileIds });
await updateProfileIncludes(newProfile.id, {
includes: includedProfileIds,
});
}
setIsCreating(false);
setSelectedProfileId(newProfile.id);
setSaveStatus("saved");
await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === newProfile.id);
const refreshed = (await listConfigProfiles()).find(
(p) => p.id === newProfile.id,
);
if (refreshed) populateForm(refreshed);
} else if (selectedProfile) {
await updateConfigProfile(selectedProfile.id, formData);
await updateProfileIncludes(selectedProfile.id, { includes: includedProfileIds });
const updatedProfile = await updateConfigProfile(
selectedProfile.id,
formData,
);
await updateProfileIncludes(selectedProfile.id, {
includes: includedProfileIds,
});
const restartOutcomes = updatedProfile.refresh_outcomes?.filter(
(outcome) => outcome.status === "restart_required",
);
if (restartOutcomes?.length) {
setError(
`Profile saved. ${restartOutcomes.length} running instance${restartOutcomes.length === 1 ? "" : "s"} must restart to adopt these changes.`,
);
}
setSaveStatus("saved");
await loadData();
const refreshed = (await listConfigProfiles()).find((p) => p.id === selectedProfile.id);
const refreshed = (await listConfigProfiles()).find(
(p) => p.id === selectedProfile.id,
);
if (refreshed) populateForm(refreshed);
}
return true;
@@ -233,7 +258,8 @@ export const useConfigProfiles = () => {
};
const handleDelete = async (id: string) => {
if (!window.confirm("Are you sure you want to delete this config profile?")) return;
if (!window.confirm("Are you sure you want to delete this config profile?"))
return;
try {
await deleteConfigProfile(id);
if (selectedProfileId === id) {
@@ -242,8 +268,8 @@ export const useConfigProfiles = () => {
resetForm();
}
await loadData();
} catch {
alert("Failed to delete config profile");
} catch (err) {
setError(extractErrorMessage(err));
}
};
@@ -268,7 +294,10 @@ export const useConfigProfiles = () => {
};
const addEnvVar = () => {
setFormData((prev) => ({ ...prev, env_vars: { ...prev.env_vars, "": "" } }));
setFormData((prev) => ({
...prev,
env_vars: { ...prev.env_vars, "": "" },
}));
setSaveStatus("idle");
};
@@ -323,7 +352,10 @@ export const useConfigProfiles = () => {
setSaveStatus("idle");
};
const updateMount = (index: number, updates: Partial<ConfigProfile["mounts"][0]>) => {
const updateMount = (
index: number,
updates: Partial<ConfigProfile["mounts"][0]>,
) => {
setFormData((prev) => {
const mounts = [...(prev.mounts || [])];
mounts[index] = { ...mounts[index], ...updates };
@@ -0,0 +1,33 @@
# Live Config Profile Refresh
## Summary
Refresh profile-managed configuration for running tool instances when a Config Profile is saved, without recreating the container or terminal session.
## Scope
- Resolve the saved profile and refresh every running instance that selected it, including profiles that include it.
- Store non-Git profile configuration as a canonical, host-side working copy shared by every instance using the profile.
- Bind-mount canonical profile directories directly into their configured container targets and bind-mount canonical profile files individually under the container working directory, preserving the workspace mount.
- Allow UI and container-side edits to the same canonical files; last writer wins, with an overwrite warning when detectable.
- Defer Git mount refresh and Git-clone mutation to a separate commit-aware feature.
- Standardize all supported tool containers on one shared non-root user/group so canonical writable profile mounts remain accessible across instances.
- Return per-instance refresh results to the profile-save UI.
## Constraints
- Preserve running containers and terminal sessions.
- Preserve the workspace/repository mount.
- Docker bind-mount topology is immutable at runtime. Added, removed, retargeted, or mode-changed mounts MUST be reported as requiring restart, not partially applied.
- Desktop and mobile profile editors use the same save/refresh behavior.
- The standardized container user/group MUST be applied to built-in tool definitions, generated manifests, and image templates; legacy/incompatible tool images must report incompatible permissions rather than silently changing profile mount ownership.
## Acceptance Criteria
- [ ] Saving a profile refreshes every eligible running instance with a direct or transitive dependency on that profile.
- [ ] Canonical non-Git profile files and mount directories are shared writable working copies across compatible running instances.
- [ ] Container-side and UI-side changes become visible to all instances using the profile; last writer wins and detectable overwrites generate a warning.
- [ ] Git mount refresh and Git clone mutation are not performed by this feature.
- [ ] Built-in supported tool containers use a shared non-root user/group compatible with writable canonical profile mounts.
- [ ] Topology changes return a restart-required result without recreating the instance.
- [ ] Terminal WebSocket sessions remain connected throughout a successful refresh.
@@ -0,0 +1,17 @@
# Design: Live Config Profile Refresh
## Canonical working copies
Each non-Git Config Profile owns canonical host-side storage. Directory mounts use canonical profile directories; each top-level profile file uses a canonical host file bind-mounted under the container working directory. All compatible instances selected for the profile mount the same sources, so UI and container edits are immediately shared.
## Container compatibility
Supported built-in tools standardize on one non-root user/group. Existing instances retain their current image/user and report `restart_required`. Custom tools are not rewritten; tools that do not opt into the shared user/group return `incompatible_permissions`.
## Save behavior
A profile save writes canonical profile files atomically per file. The save response reports affected compatible instances, `restart_required` topology/runtime changes, `incompatible_permissions`, and detectable overwrite warnings. Last writer wins; no merge or lock protocol is imposed.
## Scope boundaries
Git mount mutation is explicitly deferred. Workspace/repository mounts are never changed. Profile deletion is rejected while running instances still use the profile.
@@ -0,0 +1,19 @@
# Implementation Plan: Live Config Profile Refresh
1. Standardize built-in tool user/group definitions and remove ownership-changing behavior for shared profile sources.
2. Add canonical profile storage and bind-mount compilation for profile directories and individual working-directory files.
3. Add compatibility/topology analysis, running-instance discovery, save-result schema, and deletion guard.
4. Wire desktop/mobile profile save results into immediate status/warning feedback.
5. Add unit, API, manifest/image, and frontend coverage; run quality gates and manual two-instance QA.
## Delivery order
1. Container-user compatibility
2. Canonical non-Git profile mounts
3. API and deletion semantics
4. UI feedback
5. Verification and commit
## Deferred
Git mount refresh and Git clone mutation require a commit-aware follow-up change.
@@ -0,0 +1,32 @@
# Live Config Profile Refresh — Tasks
## Review Workload Forecast
| Field | Value |
| --- | --- |
| Estimated changed lines | 500750 |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | Container-user standardization → canonical profile mounts → API/UI feedback → verification |
| Delivery strategy | feature-branch-chain |
| Chain strategy | feature-branch-chain |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: feature-branch-chain
400-line budget risk: High
## Tasks
- [ ] **RED/GREEN — shared container user:** standardize built-in tool images, manifests, and permission handling on one shared non-root user/group; detect incompatible legacy images.
- [ ] **RED/GREEN — canonical profile storage:** create canonical host-side directories/files per profile and mount them directly into compatible instances, without masking workspace mounts.
- [ ] **TRIANGULATE — writable sharing:** prove UI and container edits are shared across instances, with last-writer-wins overwrite warnings.
- [ ] **RED/GREEN — topology/API contract:** return restart-required or incompatible-permissions outcomes for paths that cannot mount live; defer Git mount mutation.
- [ ] **RED/GREEN — UI feedback:** show shared-working-copy, warning, restart-required, and incompatible-permissions results in desktop and mobile profile editors.
- [ ] **Verify:** run targeted backend/frontend tests, typecheck, lint, image/manifest checks, and manual multi-instance permission tests.
## Verification Notes
- Passed: frontend production build (`npm run build`), Python compilation for changed backend modules, and targeted LSP diagnostics.
- Skipped: backend pytest and Ruff; this environment has no project-managed Python runner, system Python lacks those packages, and the user declined system-package installation.
- Skipped: Docker/Compose and manual multi-instance checks; explicit Docker approval was not granted.
@@ -0,0 +1,28 @@
# Test Plan: Live Config Profile Refresh
## Backend
- Canonical file and directory paths are profile-scoped and reject traversal.
- Two compatible instances receive the same host mount source.
- A container-side file edit is visible through the profile read API and another instance mount.
- A profile save updates canonical content and returns overwrite warnings when applicable.
- Topology, environment, runtime, and legacy-instance changes return `restart_required`.
- Incompatible users return `incompatible_permissions`.
- Deleting a profile with running dependents is rejected with their instance identifiers.
- Git mount content is unchanged by this feature.
## Container/image compatibility
- Each built-in supported image uses the common non-root UID/GID.
- Generated manifest Dockerfiles and entrypoints retain that user and writable mount access.
- Existing instances are not mutated until restart/recreation.
## Frontend
- Desktop and mobile save flows display refreshed/shared-working-copy, overwrite-warning, restart-required, and incompatible-permissions results.
## Manual QA
- Open two compatible instances using one profile; edit a mounted file in one terminal and verify it in the other.
- Save a profile edit and verify both running instances see it without terminal disconnection.
- Verify profile deletion is blocked while either instance is running.
+4 -3
View File
@@ -20,9 +20,10 @@ RUN apt-get update && apt-get install -y \
sudo \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user and allow passwordless sudo so the startup
# permission fixer can adjust ownership of bind-mounted directories.
RUN useradd -m -s /bin/bash user \
# Use the shared built-in UID/GID so writable Config Profile mounts can be
# shared by compatible instances without ownership changes.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user \
&& echo "user ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/user \
&& chmod 0440 /etc/sudoers.d/user
WORKDIR /home/user
+5 -1
View File
@@ -22,7 +22,11 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Set up git
RUN git config --global init.defaultBranch main
# Code-server runs as abc user by default
# The LinuxServer entrypoint maps abc to this shared UID/GID before starting
# code-server, so writable Config Profile mounts are compatible with other
# built-in tool containers.
ENV PUID=1000 \
PGID=1000
USER abc
EXPOSE 8443
+5 -2
View File
@@ -17,7 +17,10 @@ RUN apt-get update && apt-get install -y \
# Set up git
RUN git config --global init.defaultBranch main
# Switch back to jovyan user (default for scipy-notebook)
USER ${NB_UID}
# start-notebook.py maps jovyan to this shared UID/GID and drops privileges.
# Keep the image root at entrypoint time so that mapping can occur.
ENV NB_UID=1000 \
NB_GID=1000
USER root
EXPOSE 8888
+3 -2
View File
@@ -27,8 +27,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install OpenCode
RUN npm install -g opencode
# Create non-root user
RUN useradd -m -s /bin/bash user
# Use the shared built-in UID/GID for writable Config Profile mounts.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user
# Set up git
+8 -11
View File
@@ -28,8 +28,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install Pi Coding Agent globally
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
# Create non-root user
RUN useradd -m -s /bin/bash user
# Use the shared built-in UID/GID for writable Config Profile mounts.
RUN groupadd -g 1000 user \
&& useradd -m -u 1000 -g 1000 -s /bin/bash user
WORKDIR /home/user
# Set up git
@@ -37,15 +38,11 @@ RUN git config --global init.defaultBranch main \
&& git config --global user.email "dev@headquarter.local" \
&& git config --global user.name "Developer"
# Create default tmux config
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf
# Create default ranger config
RUN mkdir -p /home/user/.config/ranger \
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf
# Set up Pi config directory
RUN mkdir -p /home/user/.pi/agent
# Create user-owned default configuration files.
RUN printf '%s\n' 'set -g mouse on' 'set -g default-terminal "screen-256color"' > /home/user/.tmux.conf \
&& mkdir -p /home/user/.config/ranger /home/user/.pi/agent \
&& printf '%s\n' 'set preview_files true' 'set use_preview_script true' > /home/user/.config/ranger/rc.conf \
&& chown -R user:user /home/user
USER user