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:
commit
b718cf436f
6 changed files with 108 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue