diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd45b7a..4fb15111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.204] — 2026-06-01 — Release FX (stage-batch17 — project/session operations honor the session's own profile) + +### Fixed +- Project and session operations (project create/rename/recolor/delete/unassign, session move, and the profile chip label) now key on the session's own profile (`S.session.profile`) instead of the global active profile, so switching between sessions from different profiles no longer causes silent 404s, misleading chip labels, or project-picker entries from the wrong profile. The project picker also filters to the session's profile and surfaces an error toast on failure instead of a silent no-op (#3331, @PINKIIILQWQ). + ## [v0.51.203] — 2026-06-01 — Release FW (stage-batch15 — sticky manual unpin for streaming chat scroll) ### Changed diff --git a/api/routes.py b/api/routes.py index f925548b..a02e6977 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6840,14 +6840,20 @@ def handle_post(handler, parsed) -> bool: target_pid = body.get("project_id") or None if target_pid: from api.profiles import get_active_profile_name - active_profile = get_active_profile_name() + # Use the session's own profile for authorization, not the global + # active profile. A session belongs to a specific profile set at + # creation; projects from that profile should always be assignable, + # regardless of which profile is "active" at the process level. + # Matches the same principle as the profile chip fix — prefer + # session-scoped state over global active profile. (#3325 follow-up) + _session_profile = getattr(s, 'profile', None) or get_active_profile_name() target = next( (p for p in load_projects() if p["project_id"] == target_pid), None, ) if not target: return bad(handler, "Project not found", 404) - if not _profiles_match(target.get("profile"), active_profile): + if not _profiles_match(target.get("profile"), _session_profile): return bad(handler, "Project not found", 404) with _get_session_agent_lock(body["session_id"]): s.project_id = target_pid @@ -6871,11 +6877,20 @@ def handle_post(handler, parsed) -> bool: if color and not _re.match(r"^#[0-9a-fA-F]{3,8}$", color): return bad(handler, "Invalid color format") projects = load_projects() + # #3331 follow-up (Codex+Opus gate): validate the optional client-supplied + # `profile` before stamping it, mirroring /api/profile/switch — otherwise a + # client could create a project tagged with an arbitrary/unknown profile, + # producing hidden cross-profile rows that can't be managed normally. + _requested_profile = str(body.get('profile') or "").strip() + if _requested_profile and _requested_profile != "default": + from api.profiles import _PROFILE_ID_RE + if not _PROFILE_ID_RE.fullmatch(_requested_profile): + return bad(handler, "invalid profile") proj = { "project_id": uuid.uuid4().hex[:12], "name": name, "color": color, - "profile": get_active_profile_name() or 'default', + "profile": _requested_profile or get_active_profile_name() or 'default', "created_at": time.time(), } projects.append(proj) diff --git a/static/panels.js b/static/panels.js index 25ec1452..cc555dae 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5322,8 +5322,18 @@ async function switchToProfile(name) { if (S.session && !sessionInProgress) { S.session.model = modelToUse; S.session.model_provider = modelState.model_provider||providerId||null; + S.session.profile = data.active || name; } } + // #3331 follow-up (Codex gate): retag the in-memory session's profile on + // ANY profile switch, even when the switched-to profile returns no + // default_model (empty session / model-less profile). Without this the + // profile chip + project-picker filter keep the stale profile after a + // switch to a model-less profile. Guarded by !sessionInProgress like the + // model patch above (don't touch a session about to be replaced). + if (S.session && !sessionInProgress) { + S.session.profile = data.active || name; + } // ── Apply workspace ──────────────────────────────────────────────────── if (data.default_workspace) { diff --git a/static/sessions.js b/static/sessions.js index dad1a083..74f48849 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4978,19 +4978,38 @@ function _showProjectPicker(session, anchorEl){ none.onclick=async()=>{ picker.remove(); document.removeEventListener('click',close); - await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})}); - // Sidebar rows are shallow copies of _allSessions entries (see - // _attachChildSessionsToSidebarRows), so mutating `session` only updates - // the discarded copy. Write into the authoritative cache so the next - // renderSessionListFromCache() reflects the move. (#2551) - const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id); - if(idx>=0) _allSessions[idx].project_id=null; - renderSessionListFromCache(); - showToast('Removed from project'); + try { + await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:null})}); + // Sidebar rows are shallow copies of _allSessions entries (see + // _attachChildSessionsToSidebarRows), so mutating `session` only updates + // the discarded copy. Write into the authoritative cache so the next + // renderSessionListFromCache() reflects the move. (#2551) + const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id); + if(idx>=0) _allSessions[idx].project_id=null; + renderSessionListFromCache(); + showToast('Removed from project'); + } catch(e) { + showToast('Unassign failed: '+(e.message||e)); + } }; picker.appendChild(none); - // Project options + // Project options — only show projects matching the session's profile. + // #3331 follow-up (Codex gate): mirror the server's root-alias tolerance — + // `_profiles_match` treats the literal 'default' and a renamed-root display + // name as equivalent, so a server-approved `profile:'default'` project must + // not be hidden for a session stamped with the renamed-root profile (and + // vice versa). Only hide when BOTH sides are explicit, distinct, AND neither + // is the 'default' alias; let the server's allowlist be authoritative for the + // default/renamed-root case. + const sessionProfile = session ? (session.profile || undefined) : undefined; + const _profileHidesProject = (projProfile) => { + if(!sessionProfile || !projProfile) return false; + if(projProfile === sessionProfile) return false; + if(projProfile === 'default' || sessionProfile === 'default') return false; + return true; + }; for(const p of _allProjects){ + if (_profileHidesProject(p.profile)) continue; const item=document.createElement('div'); item.className='project-picker-item'+(session.project_id===p.project_id?' active':''); if(p.color){ @@ -5005,12 +5024,14 @@ function _showProjectPicker(session, anchorEl){ item.onclick=async()=>{ picker.remove(); document.removeEventListener('click',close); - await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})}); - // See #2551 — write to _allSessions, not the shallow sidebar copy. - const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id); - if(idx>=0) _allSessions[idx].project_id=p.project_id; - renderSessionListFromCache(); - showToast('Moved to '+p.name); + try{ + await api('/api/session/move',{method:'POST',body:JSON.stringify({session_id:session.session_id,project_id:p.project_id})}); + // See #2551 — write to _allSessions, not the shallow sidebar copy. + const idx=_allSessions.findIndex(s=>s&&s.session_id===session.session_id); + if(idx>=0) _allSessions[idx].project_id=p.project_id; + renderSessionListFromCache(); + showToast('Moved to '+p.name); + }catch(e){showToast('Move failed: '+(e.message||e));} }; picker.appendChild(item); } @@ -5028,7 +5049,8 @@ function _showProjectPicker(session, anchorEl){ }); if(!name||!name.trim()) return; const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length]; - const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color})}); + const profile = session.profile || undefined; + const res=await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:name.trim(),color,profile})}); if(res.project){ _allProjects.push(res.project); // Now move session into it @@ -5119,9 +5141,13 @@ function _startProjectRename(proj, chip){ inp.value=proj.name; const finish=async(save)=>{ if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){ - await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})}); - await renderSessionList(); - showToast('Project renamed'); + try { + await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})}); + await renderSessionList(); + showToast('Project renamed'); + } catch(e) { + showToast('Rename failed: '+(e.message||e)); + } }else{ renderSessionListFromCache(); } @@ -5172,9 +5198,13 @@ function _showProjectContextMenu(e, proj, chip){ if(hex===(proj.color||'')) dot.style.outline='2px solid var(--text)'; dot.onclick=async()=>{ menu.remove(); - await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})}); - await renderSessionList(); - showToast('Color updated'); + try { + await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})}); + await renderSessionList(); + showToast('Color updated'); + } catch(e) { + showToast('Color update failed: '+(e.message||e)); + } }; colorRow.appendChild(dot); }); @@ -5204,10 +5234,14 @@ async function _confirmDeleteProject(proj){ danger:true }); if(!ok){return;} - await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})}); - if(_activeProject===proj.project_id) _activeProject=null; - await renderSessionList(); - showToast('Project deleted'); + try { + await api('/api/projects/delete',{method:'POST',body:JSON.stringify({project_id:proj.project_id})}); + if(_activeProject===proj.project_id) _activeProject=null; + await renderSessionList(); + showToast('Project deleted'); + } catch(e) { + showToast('Delete failed: '+(e.message||e)); + } } // Global Escape handler for batch select mode diff --git a/static/ui.js b/static/ui.js index 863d1b4a..1c84f293 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5419,7 +5419,7 @@ function syncTopbar(){ // modelSelect already set above // Update profile chip label const profileLabel=$('profileChipLabel'); - if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; + if(profileLabel) profileLabel.textContent=(S.session&&S.session.profile)||S.activeProfile||'default'; } function msgContent(m){ diff --git a/tests/test_issue1614_project_profile_filtering.py b/tests/test_issue1614_project_profile_filtering.py index 8b3734b5..566e18e6 100644 --- a/tests/test_issue1614_project_profile_filtering.py +++ b/tests/test_issue1614_project_profile_filtering.py @@ -234,8 +234,15 @@ def test_profile_field_on_project_dict_default_create(monkeypatch): assert create_idx > 0 next_handler_idx = src.find('"/api/projects/rename"', create_idx) create_block = src[create_idx:next_handler_idx] - assert '"profile": get_active_profile_name() or \'default\'' in create_block, ( - "Project create must stamp the active profile (#1614)" + # The create handler must stamp the profile from a (validated) body value or + # the active profile. #3331 follow-up: the raw body value is now validated + # via _PROFILE_ID_RE before stamping, so the expression reads `_requested_profile`. + assert '"profile": _requested_profile or get_active_profile_name() or \'default\'' in create_block, ( + "Project create must stamp the active profile or accept a validated profile from body (#1614/#3331)" + ) + # And the validation guard must be present (reject unknown/invalid profile ids). + assert '_PROFILE_ID_RE.fullmatch(_requested_profile)' in create_block, ( + "Project create must validate a client-supplied profile before stamping it (#3331)" ) @@ -265,16 +272,16 @@ def test_project_delete_rejects_cross_profile(): ) -def test_session_move_rejects_cross_profile_project(): - """/api/session/move must refuse moves into a project from another profile.""" +def test_session_move_uses_session_profile(): + """/api/session/move must use session.profile instead of active_profile for authorization.""" from pathlib import Path src = (Path(__file__).parent.parent / 'api' / 'routes.py').read_text(encoding='utf-8') move_idx = src.find('"/api/session/move"') assert move_idx > 0 move_block = src[move_idx:move_idx + 2000] - assert '_profiles_match(target.get("profile"), active_profile)' in move_block, ( - "session/move must check target project's active-profile ownership" + assert '_profiles_match(target.get("profile"), _session_profile)' in move_block, ( + "session/move must use session-scoped profile (not active_profile) for authorization" )