feat(isolation): implement Phase 1-3 session isolation with stop purge and resume safety

This commit is contained in:
2026-07-10 12:24:35 +09:00
parent 35068f7a9e
commit 768cfe5c6d
6 changed files with 451 additions and 62 deletions
@@ -35,6 +35,8 @@ Options:
--tmux-server NAME specify isolated tmux server name
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
--onboard automatically submit a project alignment/orientation job to the new agent
--isolate provision an isolated per-session state home (.mam/agent_homes/<uuid>)
so same-CLI multi-role sessions never share a conversation id
-h, --help this help
EOF
}
@@ -48,6 +50,7 @@ DRY_RUN=0
TMUX_SERVER_OPT=""
SUBMIT_JOB_PROMPT=""
ONBOARD=0
ISOLATE=0
while [ $# -gt 0 ]; do
case "$1" in
@@ -60,6 +63,7 @@ while [ $# -gt 0 ]; do
--tmux-server) TMUX_SERVER_OPT="$2"; shift 2 ;;
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
--onboard) ONBOARD=1; shift ;;
--isolate) ISOLATE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
esac
@@ -111,28 +115,58 @@ if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
exit 3
fi
# T3: 세션 격리 프로비저닝 (all-L2) — 격리 홈 생성 + auth/설정 심링크 시딩
# (implementation_plan.session_isolation.md Rev.3 / Phase 0 실측 매트릭스 기준)
ISOLATION_UUID=""
ISOLATION_ROOT=""
ISOLATION_LEVER=""
ISOLATION_SEEDED=""
if [ "$ISOLATE" = "1" ]; then
command -v uuidgen >/dev/null || { echo "ERROR: uuidgen not found (required for --isolate)" >&2; exit 1; }
WORKSPACE_ABS="$(cd "$WORKSPACE" && pwd)"
ISOLATION_UUID="$(uuidgen)"
ISOLATION_ROOT="$WORKSPACE_ABS/.mam/agent_homes/$ISOLATION_UUID"
ISOLATION_LEVER="$(isolation_lever "$AGENT")"
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would provision isolation: lever=$ISOLATION_LEVER root=$ISOLATION_ROOT"
else
ISOLATION_SEEDED="$(provision_isolation "$AGENT" "$ISOLATION_ROOT")"
echo "isolation: lever=$ISOLATION_LEVER root=$ISOLATION_ROOT"
fi
fi
# tmux 세션 띄우기
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
# cmd_full 결정 — T4: isolation 디스패치(env prefix / CLI args)를 시작 명령에 주입.
# 격리 미사용 시 기존 문자열과 byte-identical (V4 회귀 0).
ISO_ENV_PREFIX=""
ISO_CMD_ARGS=""
if [ -n "$ISOLATION_ROOT" ]; then
ISO_ENV_PREFIX="$(isolation_env_prefix "$AGENT" "$ISOLATION_ROOT")"
ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")"
fi
case "$AGENT" in
claude) CMD_FULL="${ISO_ENV_PREFIX}claude --dangerously-skip-permissions" ;;
agy) CMD_FULL="${ISO_ENV_PREFIX}agy --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${ISO_ENV_PREFIX}hermes" ;;
cline) CMD_FULL="cline -i${ISO_CMD_ARGS:+ $ISO_CMD_ARGS}" ;;
esac
spawn() {
case "$AGENT" in
claude)
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
# 격리 시 wrapper 경로는 env 주입을 운반하지 못하므로 인라인 spawn 강제
if [ "$ISOLATE" != "1" ] && { { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; }; then
nohup "$WRAPPER" >/dev/null 2>&1 &
disown
else
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude --dangerously-skip-permissions"
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
fi
;;
agy)
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
;;
hermes)
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "hermes"
;;
cline)
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "cline -i"
agy|hermes|cline)
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
;;
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT" >&2; exit 2 ;;
esac
@@ -151,6 +185,12 @@ cleanup_tmux_on_error() {
if [ $exit_code -ne 0 ]; then
echo "⚠️ Error occurred during initialization. Rolling back and killing tmux session '$SESSION_NAME'..." >&2
_tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
# T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm)
if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then
case "$ISOLATION_ROOT" in
*/.mam/agent_homes/*) rm -rf "$ISOLATION_ROOT" ;;
esac
fi
fi
}
trap cleanup_tmux_on_error EXIT
@@ -165,15 +205,7 @@ PANE_CMD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/d
TMUX_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# cmd_full 결정
case "$AGENT" in
claude) CMD_FULL='claude --dangerously-skip-permissions' ;;
agy) CMD_FULL='agy --dangerously-skip-permissions' ;;
hermes) CMD_FULL='hermes' ;;
cline) CMD_FULL='cline -i' ;;
esac
# 시작 명령
# 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4)
local_tmux="tmux"
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
local_tmux="tmux -L $TMUX_SERVER_NAME"
@@ -181,10 +213,10 @@ fi
case "$AGENT" in
claude)
if [ -x "$WRAPPER" ]; then
if [ "$ISOLATE" != "1" ] && [ -x "$WRAPPER" ]; then
START_CMD="$WRAPPER # ~/.local/bin 의 래퍼"
else
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"claude --dangerously-skip-permissions\""
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
fi
;;
agy|hermes|cline)
@@ -238,7 +270,9 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
TMUX_EPOCH="$TMUX_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" \
ISOLATION_UUID="$ISOLATION_UUID" ISOLATION_ROOT="$ISOLATION_ROOT" \
ISOLATION_LEVER="$ISOLATION_LEVER" ISOLATION_SEEDED="$ISOLATION_SEEDED" <<'PYEOF'
name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
role = os.environ['ROLE']
@@ -277,6 +311,16 @@ entry = {
'kill_command': f'tmux {server_opt}kill-session -t {name}',
}
# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용
iso_uuid = os.environ.get('ISOLATION_UUID', '')
if iso_uuid:
entry['isolation'] = {
'uuid': iso_uuid,
'root': os.environ.get('ISOLATION_ROOT', ''),
'lever': os.environ.get('ISOLATION_LEVER', ''),
'seeded': [x for x in os.environ.get('ISOLATION_SEEDED', '').split(',') if x],
}
if agent == 'claude':
entry['tui'] = {
'model': '(unknown — capture after first message)',