Merge pull request #3361 from nesquena/release/stage-batch17

v0.51.204: project/session operations honor the session's own profile (#3331)
This commit is contained in:
nesquena-hermes 2026-06-01 14:12:01 -07:00 committed by GitHub
commit b718cf436f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 108 additions and 37 deletions

View file

@ -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

View file

@ -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)

View file

@ -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) {

View file

@ -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

View file

@ -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){

View file

@ -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"
)