Make session visibility preference-driven and keep system-channel ACL separate.

Super/fallback can toggle view-all (default on); admin/user stay own-only while still seeing unowned system sessions. Add restore/prune helpers so empty-shell cleanup is explicit and recoverable.
This commit is contained in:
oliver 2026-09-14 20:48:15 +08:00
parent c26dc68f77
commit ad0cc98b38
17 changed files with 898 additions and 93 deletions

View file

@ -46,7 +46,7 @@ originSystemCode: ''
2. 把输出的 `UDS_AUTH_LOCAL_ADMIN_BOX=...` 设到 **Harness 进程环境**(这是密文,不是口令)
3. 登录面板 →「本机密钥解锁」→ 输入口令;**必须解密成功才有 admin**
仅设置环境变量、不知道口令 → **无法登录**。旧变量 `UDS_AUTH_LOCAL_ADMIN_KEY` 已忽略。
- **ACL**:`super_admin` / 兜底 `administrator` 可见全部会话(含 `@` 提及);`admin` / `user` 仅可见 **自己拥有的** 或 **自己工作区路径下的** 会话。侧栏、`session/search` 与 `@` 候选共用同一规则
- **ACL / 侧栏**:未登录与普通用户看不到设置齿轮;**仅超管/应急**可见设置。`admin` 无设置齿轮,但可看渠道/系统会话。`super_admin` / 兜底默认可见全部会话(可关)。布局:设置在左、登录在右。
## Skill 认证(给他人改造 skill 时)

View file

@ -188,6 +188,27 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
})
}
async function setViewAllSessions(ctx) {
if (!requirePermission(ctx, 'canToggleViewAllSessions')) {
return fail(ctx, 'forbidden_view_all_sessions', 403)
}
const body = await readBody(ctx.req)
const enabled = !!(body && body.enabled)
try {
rolesStore.setViewAllSessions(ctx.empNo, enabled)
if (typeof rolesStore.flush === 'function') {
await rolesStore.flush()
}
ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role)
await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, {
permissions: ctx.permissions,
viewAllSessions: enabled,
})
} catch (err) {
await mapThrown(ctx, err, 403)
}
}
return {
logout,
getCurrentUser,
@ -198,6 +219,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
setFallbackPassword,
clearFallbackPassword,
fallbackStatus,
setViewAllSessions,
}
}

View file

@ -66,6 +66,11 @@ window.__ModuleLoader__.load({
"ui.nextPage": "下一页",
"ui.add": "添加",
"ui.department": "部门",
"ui.viewAllSessionsTitle": "查看全部会话",
"ui.viewAllSessionsIntro": "超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。",
"ui.viewAllSessionsToggle": "显示所有人的会话",
"ui.viewAllSessionsOn": "已开启:可见全部会话",
"ui.viewAllSessionsOff": "已关闭:仅可见自己的会话",
"ui.notLoggedIn": "未登录",
"ui.pleaseScan": "请使用 iCenter 扫码登录",
"ui.refreshQr": "刷新二维码",
@ -106,6 +111,7 @@ window.__ModuleLoader__.load({
"err.forbidden_remove_user": "只有超级管理员可以删除用户",
"err.forbidden_set_fallback": "只有超级管理员可以设置应急密码",
"err.forbidden_clear_fallback": "只有超级管理员可以清除应急密码",
"err.forbidden_view_all_sessions": "当前角色不能开启查看全部会话",
"err.invalid_role_params": "参数错误: empNo 和 role 必填",
"err.emp_no_required": "empNo 必填",
"err.username_password_required": "用户名和密码必填",
@ -151,7 +157,9 @@ window.__ModuleLoader__.load({
"ok.fallback_password_set": "应急管理员密码已设置",
"ok.fallback_password_cleared": "应急管理员密码已清除",
"ok.fallback_login": "应急管理员登录成功",
"ok.local_admin_unlock": "本机密钥解锁成功"
"ok.local_admin_unlock": "本机密钥解锁成功",
"ok.view_all_sessions_on": "已开启查看全部会话",
"ok.view_all_sessions_off": "已关闭查看全部会话"
},
"en": {
"role.super_admin": "Super admin",
@ -198,6 +206,11 @@ window.__ModuleLoader__.load({
"ui.nextPage": "Next",
"ui.add": "Add",
"ui.department": "Department",
"ui.viewAllSessionsTitle": "View all sessions",
"ui.viewAllSessionsIntro": "Super admins see all sessions by default (including channels). Turn off to only see your own; sidebar and @ mentions share the same rule.",
"ui.viewAllSessionsToggle": "Show everyone’s sessions",
"ui.viewAllSessionsOn": "On: all sessions visible",
"ui.viewAllSessionsOff": "Off: only your own sessions",
"ui.notLoggedIn": "Not signed in",
"ui.pleaseScan": "Scan with iCenter to sign in",
"ui.refreshQr": "Refresh QR",
@ -238,6 +251,7 @@ window.__ModuleLoader__.load({
"err.forbidden_remove_user": "Only super admins can remove users",
"err.forbidden_set_fallback": "Only super admins can set the emergency password",
"err.forbidden_clear_fallback": "Only super admins can clear the emergency password",
"err.forbidden_view_all_sessions": "Your role cannot enable view-all sessions",
"err.invalid_role_params": "Invalid params: empNo and role required",
"err.emp_no_required": "empNo required",
"err.username_password_required": "Username and password required",
@ -283,7 +297,9 @@ window.__ModuleLoader__.load({
"ok.fallback_password_set": "Emergency admin password set",
"ok.fallback_password_cleared": "Emergency admin password cleared",
"ok.fallback_login": "Emergency admin signed in",
"ok.local_admin_unlock": "Local admin unlocked"
"ok.local_admin_unlock": "Local admin unlocked",
"ok.view_all_sessions_on": "View-all sessions enabled",
"ok.view_all_sessions_off": "View-all sessions disabled"
}
}
const UDS_HOST_ARIA = {
@ -396,14 +412,21 @@ window.__ModuleLoader__.load({
const CSS = [
'.uds-auth-host{position:relative;display:inline-flex;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto}.uds-auth-host.is-rail{justify-content:center;width:100%}[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px;width:100%}[data-uds-auth-foot="row"]>*:nth-child(1){order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}[data-uds-auth-foot="row"]>*:nth-child(2){order:1;flex:none!important;width:auto!important;min-width:0}',
'html[data-uds-can-settings="0"] [data-uds-auth-foot="row"]>*:not(:has([data-uds-auth-host])){display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}',
'.uds-auth-host{position:relative;display:inline-flex!important;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto;visibility:visible!important;opacity:1!important}.uds-auth-host.is-rail{justify-content:center;width:100%}',
/* Foot: Settings left, login right. Marked via data-uds-foot-slot by AuthBadge. */
'[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;flex-wrap:nowrap!important;align-items:center!important;gap:8px;width:100%}',
'[data-uds-auth-foot="row"]>[data-uds-foot-slot="settings"]{order:1;flex:none!important;width:auto!important;min-width:0}',
'[data-uds-auth-foot="row"]>[data-uds-foot-slot="login"]{order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}',
/* Hide settings when logged out or no settings permission (super/fallback only). */
'html[data-uds-can-settings="0"] [data-uds-foot-slot="settings"],html[data-uds-logged-in="0"] [data-uds-foot-slot="settings"]{display:none!important}',
'html[data-uds-can-settings="0"] button[aria-label="设置"],html[data-uds-can-settings="0"] button[aria-label="Settings"],html[data-uds-logged-in="0"] button[aria-label="设置"],html[data-uds-logged-in="0"] button[aria-label="Settings"]{display:none!important}',
'html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}',
/* uds-session-only-sidebar */
'html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}',
'.uds-auth-badge{display:inline-flex;align-items:center;justify-content:flex-start;gap:0;max-width:min(160px,42vw);min-width:0;height:32px;padding:0 8px;box-sizing:border-box;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;font-weight:400;line-height:20px;cursor:pointer;overflow:hidden}',
'.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}',
'.uds-auth-host.is-rail .uds-auth-badge{width:auto;max-width:100%;height:32px;padding:0 6px;border-radius:8px}',
'.uds-auth-badge-unauth{color:var(--dsw-alias-label-tertiary,#8f959e)}',
'.uds-auth-badge-unauth{color:var(--dsw-alias-label-primary,#e8eaed)}',
'.uds-auth-avatar{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;flex:none;font-size:10px;line-height:1;color:var(--dsw-alias-label-secondary,#646a73);background:transparent;border:none}',
'.uds-auth-badge-unauth .uds-auth-avatar{background:var(--dsw-alias-bg-module-platform,rgba(242,243,245,1));color:var(--dsw-alias-label-tertiary,#8f959e)}',
'.uds-auth-badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
@ -461,6 +484,10 @@ window.__ModuleLoader__.load({
'.uds-auth-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)}',
'.uds-auth-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}',
'.uds-auth-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px}',
'.uds-auth-settings-toggle{display:flex;align-items:flex-start;gap:10px;margin:8px 0 4px;cursor:pointer;user-select:none}',
'.uds-auth-settings-toggle input{margin-top:3px;flex-shrink:0}',
'.uds-auth-settings-toggle span{font-size:13px;line-height:1.4;color:var(--dsw-alias-label-primary,#1f2329)}',
'.uds-auth-settings-toggle-status{margin:4px 0 0;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}',
].join('')
function getCookie(cookieName) {
@ -844,6 +871,8 @@ function reloadAfterLogin() {
const [msg, setMsg] = useState('')
const [msgKind, setMsgKind] = useState('')
const [busy, setBusy] = useState(false)
const [viewAllBusy, setViewAllBusy] = useState(false)
const [viewAllMsg, setViewAllMsg] = useState('')
useEffect(() => {
let cancelled = false
@ -869,6 +898,8 @@ function reloadAfterLogin() {
const perms = me?.permissions || {}
const canManage = !!perms.canManageUsers
const canSettings = !!perms.canAccessSettings
const canToggleViewAll = !!perms.canToggleViewAllSessions
const viewAllOn = !!perms.canViewAllSessions
const saveConfig = async () => {
setBusy(true)
@ -889,6 +920,31 @@ function reloadAfterLogin() {
}
}
const setViewAllSessions = async (enabled) => {
setViewAllBusy(true)
setViewAllMsg('')
try {
const res = await fetchJson('/uds-auth/api/me/view-all-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !!enabled }),
})
setMe((prev) => prev ? {
...prev,
permissions: res.permissions || {
...prev.permissions,
canViewAllSessions: !!enabled,
},
} : prev)
setViewAllMsg(res.message || (enabled ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')))
try { softReconnectAuth() } catch { /* ignore */ }
} catch (err) {
setViewAllMsg(apiMessage(err) || t('ui.saveFailed'))
} finally {
setViewAllBusy(false)
}
}
const field = (key, label) => h('div', { className: 'uds-auth-settings-field' },
h('label', { htmlFor: 'uds-auth-' + key }, label),
h('input', {
@ -905,9 +961,25 @@ function reloadAfterLogin() {
h('p', { className: 'uds-auth-settings-intro' }, t('ui.settingsIntro')),
),
!me && h('div', { className: 'uds-auth-settings-empty' }, t('ui.loginRequiredPage')),
me && !canSettings && !canManage && h('div', { className: 'uds-auth-settings-empty' },
me && !canSettings && !canManage && !canToggleViewAll && h('div', { className: 'uds-auth-settings-empty' },
t('ui.roleHint', { role: me.role || 'user' })
),
canToggleViewAll && h('div', { className: 'uds-auth-settings-card' },
h('h3', null, t('ui.viewAllSessionsTitle')),
h('p', { className: 'uds-auth-settings-intro' }, t('ui.viewAllSessionsIntro')),
h('label', { className: 'uds-auth-settings-toggle' },
h('input', {
type: 'checkbox',
checked: viewAllOn,
disabled: viewAllBusy,
onChange: (e) => setViewAllSessions(e.target.checked),
}),
h('span', null, t('ui.viewAllSessionsToggle')),
),
h('p', { className: 'uds-auth-settings-toggle-status' },
viewAllMsg || (viewAllOn ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')),
),
),
canSettings && h('div', { className: 'uds-auth-settings-card' },
h('h3', null, t('ui.deployConfig')),
field('uacBaseUrl', 'UAC Base URL'),
@ -987,13 +1059,23 @@ function reloadAfterLogin() {
let footArea = null
for (let el = host.parentElement; el && el !== document.body; el = el.parentElement) {
if (el.childElementCount < 2) continue
const mine = [...el.children].some((c) => c.contains(host))
const other = [...el.children].some((c) => !c.contains(host))
const mine = [...el.children].some((c) => c.contains(host) || c === host)
const other = [...el.children].some((c) => !(c.contains(host) || c === host))
if (mine && other) { footArea = el; break }
}
if (!footArea) return undefined
footArea.setAttribute('data-uds-auth-foot', 'row')
return () => { footArea.removeAttribute('data-uds-auth-foot') }
const marked = []
for (const child of footArea.children) {
const isLogin = child === host || child.contains(host)
const slot = isLogin ? 'login' : 'settings'
child.setAttribute('data-uds-foot-slot', slot)
marked.push(child)
}
return () => {
footArea.removeAttribute('data-uds-auth-foot')
for (const child of marked) child.removeAttribute('data-uds-foot-slot')
}
}, [])
const [open, setOpen] = useState(false)
const [anchor, setAnchor] = useState(null)
@ -1517,6 +1599,9 @@ function reloadAfterLogin() {
tag.setAttribute('data-plugin', name)
tag.textContent = CSS
document.head.appendChild(tag)
} else {
// Hot reload / plugin update: refresh rules (e.g. login-host visibility fix).
document.getElementById('uds-auth-client-css').textContent = CSS
}
// Register login entry before heavy gates / settings section.
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({

View file

@ -2,12 +2,25 @@
* Bridge UDS cookies → AsyncLocalStorage and wrap DSH session/workspace/settings.
*/
import { createRequire } from 'node:module'
import { resolve as resolvePath, sep as pathSep } from 'node:path'
import { withUserContext, getUserContext, runWithUserContext } from './context.js'
import { computePermissions, ROLES } from './roles.js'
import { resolveLocale, t } from './i18n.js'
const require = createRequire(import.meta.url)
/** True when path is outside per-user workspace root (channel/bot/harness cwd). */
export function isOutsideUserWorkspaceRoot(candidatePath, workspaceRoot) {
if (!candidatePath) return true
if (!workspaceRoot) return true
try {
const base = resolvePath(String(workspaceRoot))
const cand = resolvePath(String(candidatePath))
return cand !== base && !cand.startsWith(base + pathSep)
} catch {
return true
}
}
function parseCookie(header, name) {
if (!header || typeof header !== 'string') return null
for (const part of header.split(';')) {
@ -174,7 +187,7 @@ export function resolveIdentityFromRequestSync(req, deps) {
return {
empNo: String(empNo),
role,
permissions: computePermissions(role),
permissions: rolesStore.resolvePermissions(empNo, role),
userContext: {
empNo: String(empNo),
userId: String(empNo),
@ -229,7 +242,7 @@ export async function resolveIdentityFromRequest(req, deps) {
} catch { /* keep getRole */ }
}
const permissions = computePermissions(role)
const permissions = rolesStore.resolvePermissions(empNo, role)
return {
empNo: String(empNo),
role,
@ -372,7 +385,11 @@ function throwForbidden(code) {
/**
* Shared session visibility helpers (sidebar + @ mention + query reads).
* Visibility: super_admin / fallback_admin see all; others see owner OR own workspace.
* Visibility:
* - canViewAllSessions (super/fallback toggle): see all
* - else: own owner stamp OR own user-workspace path
* - canViewSystemSessions (admin+): also see system/channel sessions whose cwd
* is outside the per-user workspace root (IM bots, harness cwd, unstamped)
*/
export function createSessionAccess({
sessionAcl,
@ -380,18 +397,26 @@ export function createSessionAccess({
getWorkspaceRoot,
getWorkspaceRegistry,
resolveLiveCwd,
rolesStore,
}) {
const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null
/** Always re-read prefs from live rolesStore — never trust frozen WS identity.permissions. */
const canSeeAll = (identity) => {
if (!identity) return false
const empNo = empOf(identity)
const store = typeof rolesStore === 'function' ? rolesStore() : rolesStore
if (empNo && store && typeof store.resolvePermissions === 'function') {
// Do not pass identity.role — store.getRole(empNo) is source of truth.
return !!store.resolvePermissions(empNo).canViewAllSessions
}
if (identity.permissions?.canViewAllSessions) return true
// No store (tests / misconfig): keep legacy super visibility.
const role = identity.role || identity.userContext?.role
if (role === 'fallback_admin' || role === 'super_admin') return true
if (String(identity.empNo || identity.userContext?.empNo || '') === 'administrator') return true
return false
return role === 'super_admin' || role === 'fallback_admin'
|| String(empNo) === 'administrator'
}
const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null
const resolveRegistry = () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
@ -436,14 +461,21 @@ export function createSessionAccess({
const root = getWorkspaceRoot()
const wid = ws.id ?? ws.workspaceId
const path = ws.path
return userWorkspaces.isUserPath(empNo, path, root)
if (userWorkspaces.isUserPath(empNo, path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))) {
return true
}
// Channel / bot / shared harness workspaces live outside user-workspaces.
if (identity.permissions?.canViewSystemSessions && isOutsideUserWorkspaceRoot(path, root)) {
return true
}
return false
}
/**
* Owner stamp OR cwd/workspace under the caller's provisioned path.
* Missing owner alone does not deny (legacy sessions rely on workspace/cwd).
* Settings roles also see unowned system/channel sessions (cwd outside user-workspaces).
*/
const canAccessSession = (sessionId, identity, rowHint) => {
if (!identity || !empOf(identity)) return false
@ -457,12 +489,23 @@ export function createSessionAccess({
const cwd = resolveSessionCwd(sessionId, rowHint)
if (cwd && userWorkspaces.isUserPath(empNo, cwd, root)) return true
const foreignOwner = !!(owner && String(owner) !== String(empNo))
// IM/channel (and other host-internal) sessions: often unstamped + bot cwd.
// Let admin+ see those; never leak another user's stamped private session.
if (!foreignOwner && !owner && identity.permissions?.canViewSystemSessions
&& isOutsideUserWorkspaceRoot(cwd, root)) {
return true
}
const registry = resolveRegistry()
if (!registry || typeof registry.list !== 'function') return false
let workspaces = []
try { workspaces = registry.list() || [] } catch { return false }
for (const ws of workspaces) {
if (!isVisibleWorkspace(identity, ws)) continue
// Foreign-owned sessions must not become visible via channel/system workspaces.
if (foreignOwner && isOutsideUserWorkspaceRoot(ws?.path, root)) continue
if (workspaceContainsSession(ws, sessionId)) return true
}
return false
@ -480,47 +523,66 @@ export function createSessionAccess({
/**
* Install Host ACL wrappers.
* `getRolesStore` / `rolesStore` is read live on every ACL check so plugin reload
* and preference toggles take effect without re-wrapping Host controllers.
*/
export function installDshAcl(ctx, {
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
rolesStore,
getRolesStore,
ensureUserWorkspace,
getWorkspaceRegistry,
}) {
const disposers = []
const resolveRolesStore = () => {
if (typeof getRolesStore === 'function') {
try { return getRolesStore() } catch { return null }
}
if (typeof rolesStore === 'function') {
try { return rolesStore() } catch { return null }
}
return rolesStore || null
}
const access = createSessionAccess({
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
getWorkspaceRegistry: () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
// Shared live bag: re-install updates this even when Host controllers are already wrapped.
const live = installDshAcl._live || (installDshAcl._live = { access: null })
const rebuildAccess = () => {
live.access = createSessionAccess({
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
rolesStore: resolveRolesStore,
getWorkspaceRegistry: () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
}
} catch { /* ignore */ }
try { return ctx.get('workspaceRegistry') } catch { return null }
},
resolveLiveCwd: (sessionId) => {
try {
const agents = ctx.get('agents')
const agent = agents?.get?.(sessionId)
return agent?.session?.header?.cwd || null
} catch {
return null
}
} catch { /* ignore */ }
try { return ctx.get('workspaceRegistry') } catch { return null }
},
resolveLiveCwd: (sessionId) => {
try {
const agents = ctx.get('agents')
const agent = agents?.get?.(sessionId)
return agent?.session?.header?.cwd || null
} catch {
return null
}
},
})
const {
canSeeAll,
empOf,
resolveRegistry,
isVisibleWorkspace,
canAccessSession,
} = access
},
})
}
rebuildAccess()
const canSeeAll = (identity) => live.access.canSeeAll(identity)
const empOf = (identity) => live.access.empOf(identity)
const resolveRegistry = () => live.access.resolveRegistry()
const isVisibleWorkspace = (identity, ws) => live.access.isVisibleWorkspace(identity, ws)
const canAccessSession = (sessionId, identity, rowHint) => (
live.access.canAccessSession(sessionId, identity, rowHint)
)
// Stamp owner on session create
const offCreated = ctx.on('session/created', (session) => {
@ -880,12 +942,7 @@ ctx.inject(['workspaceController'], (wctx) => {
// the ungrouped bucket).
const canSeeAllWorkspaces = (identity) => {
if (!identity) return false
if (identity.permissions?.canViewAllSessions) return true
const role = identity.role || identity.userContext?.role
if (role === 'fallback_admin' || role === 'super_admin') return true
// Fallback cookie user is always administrator
if (String(identity.empNo || identity.userContext?.empNo || '') === 'administrator') return true
return false
return canSeeAll(identity)
}
const allowWorkspace = (identity, ws) => {
@ -894,9 +951,16 @@ ctx.inject(['workspaceController'], (wctx) => {
const empNo = identity.empNo || identity.userContext?.empNo
const root = getWorkspaceRoot()
const wid = ws?.workspaceId ?? ws?.id
return userWorkspaces.isUserPath(empNo, ws?.path, root)
if (userWorkspaces.isUserPath(empNo, ws?.path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))) {
return true
}
if (identity.permissions?.canViewSystemSessions
&& isOutsideUserWorkspaceRoot(ws?.path, root)) {
return true
}
return false
}
const filterBaseline = (identity, baseline) => {
@ -921,6 +985,20 @@ ctx.inject(['workspaceController'], (wctx) => {
const allowed = new Set()
const mapped = userWorkspaces.get(empNo)?.workspaceId
if (mapped != null) allowed.add(String(mapped))
// Keep channel/bot workspaces for admin+ (same rule as allowWorkspace).
if (identity?.permissions?.canViewSystemSessions) {
try {
const root = getWorkspaceRoot()
const registry = resolveRegistry()
const list = typeof registry?.list === 'function' ? (registry.list() || []) : []
for (const ws of list) {
const id = ws?.id ?? ws?.workspaceId
if (id != null && isOutsideUserWorkspaceRoot(ws?.path, root)) {
allowed.add(String(id))
}
}
} catch { /* ignore */ }
}
return {
...frame,
workspaceIds: (frame.workspaceIds || []).filter((id) => allowed.has(String(id))),

View file

@ -60,6 +60,13 @@ export const MESSAGES = {
'ui.add': '添加',
'ui.department': '部门',
// privacy / session visibility
'ui.viewAllSessionsTitle': '查看全部会话',
'ui.viewAllSessionsIntro': '超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。',
'ui.viewAllSessionsToggle': '显示所有人的会话',
'ui.viewAllSessionsOn': '已开启:可见全部会话',
'ui.viewAllSessionsOff': '已关闭:仅可见自己的会话',
// login panel
'ui.notLoggedIn': '未登录',
'ui.pleaseScan': '请使用 iCenter 扫码登录',
@ -107,6 +114,7 @@ export const MESSAGES = {
'err.forbidden_remove_user': '只有超级管理员可以删除用户',
'err.forbidden_set_fallback': '只有超级管理员可以设置应急密码',
'err.forbidden_clear_fallback': '只有超级管理员可以清除应急密码',
'err.forbidden_view_all_sessions': '当前角色不能开启查看全部会话',
'err.invalid_role_params': '参数错误: empNo 和 role 必填',
'err.emp_no_required': 'empNo 必填',
'err.username_password_required': '用户名和密码必填',
@ -155,6 +163,8 @@ export const MESSAGES = {
'ok.fallback_password_cleared': '应急管理员密码已清除',
'ok.fallback_login': '应急管理员登录成功',
'ok.local_admin_unlock': '本机密钥解锁成功',
'ok.view_all_sessions_on': '已开启查看全部会话',
'ok.view_all_sessions_off': '已关闭查看全部会话',
},
en: {
'role.super_admin': 'Super admin',
@ -203,6 +213,12 @@ export const MESSAGES = {
'ui.add': 'Add',
'ui.department': 'Department',
'ui.viewAllSessionsTitle': 'View all sessions',
'ui.viewAllSessionsIntro': 'Super admins see all sessions by default (including channels). Turn off to only see your own; sidebar and @ mentions share the same rule.',
'ui.viewAllSessionsToggle': 'Show everyone’s sessions',
'ui.viewAllSessionsOn': 'On: all sessions visible',
'ui.viewAllSessionsOff': 'Off: only your own sessions',
'ui.notLoggedIn': 'Not signed in',
'ui.pleaseScan': 'Scan with iCenter to sign in',
'ui.refreshQr': 'Refresh QR',
@ -246,6 +262,7 @@ export const MESSAGES = {
'err.forbidden_remove_user': 'Only super admins can remove users',
'err.forbidden_set_fallback': 'Only super admins can set the emergency password',
'err.forbidden_clear_fallback': 'Only super admins can clear the emergency password',
'err.forbidden_view_all_sessions': 'Your role cannot enable view-all sessions',
'err.invalid_role_params': 'Invalid params: empNo and role required',
'err.emp_no_required': 'empNo required',
'err.username_password_required': 'Username and password required',
@ -293,6 +310,8 @@ export const MESSAGES = {
'ok.fallback_password_cleared': 'Emergency admin password cleared',
'ok.fallback_login': 'Emergency admin signed in',
'ok.local_admin_unlock': 'Local admin unlocked',
'ok.view_all_sessions_on': 'View-all sessions enabled',
'ok.view_all_sessions_off': 'View-all sessions disabled',
},
}

View file

@ -768,6 +768,9 @@ function handleRequest(req, res) {
if (url === '/api/fallback/clear' && method === 'POST') {
await _apiHandlers.clearFallbackPassword(ctx2); return
}
if (url === '/api/me/view-all-sessions' && method === 'POST') {
await _apiHandlers.setViewAllSessions(ctx2); return
}
// 配置端点 (admin / super_admin:canAccessSettings)
if (url === '/api/config' && method === 'GET') {
@ -1100,7 +1103,7 @@ async function initServices(ctx, config) {
sessionAcl: _sessionAcl,
userWorkspaces: _userWorkspaces,
getWorkspaceRoot: () => _currentConfig?.workspaceRoot,
rolesStore: _rolesStore,
getRolesStore: () => _rolesStore,
ensureUserWorkspace,
getWorkspaceRegistry: () => _workspaceRegistry,
})
@ -1141,6 +1144,10 @@ async function initServices(ctx, config) {
}
},
canViewAllJobs(identity) {
const empNo = identity?.empNo || identity?.userContext?.empNo
if (empNo && _rolesStore?.resolvePermissions) {
return !!_rolesStore.resolvePermissions(empNo, identity?.role).canViewAllSessions
}
return !!identity?.permissions?.canViewAllSessions
},
getRole(empNo) {
@ -1156,7 +1163,8 @@ async function initServices(ctx, config) {
const id = String(empNo || '').trim()
if (!id || id.startsWith('__')) return null
const role = _rolesStore?.getRole?.(id) || 'user'
const permissions = computePermissions(role)
const permissions = _rolesStore?.resolvePermissions?.(id, role)
|| computePermissions(role)
const workspacePath = (() => {
try {
const row = _userWorkspaces?.get(id)

View file

@ -189,10 +189,13 @@ export function buildLocalAdminUserContext() {
export function buildLocalAdminIdentity(rolesStore) {
const empNo = DEFAULT_FALLBACK_USERNAME
const role = rolesStore?.getRole?.(empNo) || ROLES.FALLBACK_ADMIN
const permissions = typeof rolesStore?.resolvePermissions === 'function'
? rolesStore.resolvePermissions(empNo, role)
: computePermissions(role)
return {
empNo,
role,
permissions: computePermissions(role),
permissions,
userContext: buildLocalAdminUserContext(),
kind: 'fallback',
}

View file

@ -1,6 +1,6 @@
import { UdsClient } from '../uds/client.js'
import { UdsValidator } from '../uds/validator.js'
import { ROLES, computePermissions } from '../roles.js'
import { ROLES } from '../roles.js'
import { searchUserByEmpNoToken } from '../uds/user-search.js'
/**
@ -108,7 +108,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
ctx.userContext = userContext
ctx.empNo = empNo
ctx.role = role
ctx.permissions = computePermissions(role)
ctx.permissions = rolesStore.resolvePermissions(empNo, role)
}
async function authMiddleware(ctx, next) {
@ -207,7 +207,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
ctx.userContext = userContext
ctx.empNo = profile.empNo
ctx.role = role
ctx.permissions = computePermissions(role)
ctx.permissions = rolesStore.resolvePermissions(profile.empNo, role)
return next()
}

View file

@ -1,13 +1,15 @@
/**
* uds-auth 角色存储 + 权限管理
*
*
* 角色:
* super_admin 所有权限 + 用户管理 + 可见全部会话(含 @)
* super_admin 所有权限 + 用户管理;默认可见全部会话(可在设置中关闭)
* fallback_admin 等同 super_admin(兜底 administrator)
* admin 设置权限 + 仅看自己会话(含 @)
* admin 无设置齿轮;仅看自己会话(含 @);可见渠道/系统会话
* user 仅看自己会话,无设置
*
* 持久化: roles.json (单实例文件) + MemoryStore 同步
* 超管/应急默认全览开启;prefs.viewAllSessions === false 时关闭。
* 设置齿轮仅超管/应急(canAccessSettings)。
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
@ -30,40 +32,60 @@ export const ROLES = {
/** zh labels for list/search; UI should translate via i18n role.* keys. */
export const ROLE_LABELS = ROLE_LABELS_ZH
/** 计算角色权限 (纯函数) */
export function computePermissions(role) {
/**
* 计算角色权限 (纯函数)
* @param {string} role
* @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;超管默认可见全部
*/
export function computePermissions(role, opts = {}) {
const viewAll = !!opts.viewAllSessions
switch (role) {
case ROLES.SUPER_ADMIN:
return {
canManageUsers: true,
canAccessSettings: true,
canViewAllSessions: true,
canToggleViewAllSessions: true,
canViewAllSessions: viewAll,
canViewSystemSessions: true,
canCreateWorkspace: true,
}
case ROLES.FALLBACK_ADMIN:
return {
canManageUsers: true,
canAccessSettings: true,
canViewAllSessions: true,
canToggleViewAllSessions: true,
canViewAllSessions: viewAll,
canViewSystemSessions: true,
canCreateWorkspace: true,
}
case ROLES.ADMIN:
return {
canManageUsers: false,
canAccessSettings: true,
// 设置齿轮仅超管/应急;admin 仍可看渠道/系统会话
canAccessSettings: false,
canToggleViewAllSessions: false,
canViewAllSessions: false,
canViewSystemSessions: true,
canCreateWorkspace: false,
}
default: // user / undefined
return {
canManageUsers: false,
canAccessSettings: false,
canToggleViewAllSessions: false,
canViewAllSessions: false,
canViewSystemSessions: false,
canCreateWorkspace: false,
}
}
}
/** 角色是否允许开启「查看全部会话」(仅超管 / 应急) */
export function canToggleViewAllSessions(role) {
return role === ROLES.SUPER_ADMIN
|| role === ROLES.FALLBACK_ADMIN
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
@ -82,6 +104,7 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator'
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
this._prefs = new Map() // empNo → { viewAllSessions?: boolean }
this._firstBootLock = Promise.resolve()
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
@ -111,6 +134,11 @@ export class RolesStore {
for (const [empNo, role] of Object.entries(data.roles || {})) {
this._roles.set(empNo, role)
}
for (const [empNo, prefs] of Object.entries(data.prefs || {})) {
if (prefs && typeof prefs === 'object') {
this._prefs.set(String(empNo), { ...prefs })
}
}
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
loadedHash = data.fallbackPasswordHash || null
if (loadedHash) this._fallbackPasswordHash = loadedHash
@ -132,7 +160,16 @@ export class RolesStore {
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => this._save(), 2000)
this._saveTimer = setTimeout(() => { void this._save() }, 2000)
}
/** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */
async flush() {
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
await this._save()
}
async _save() {
@ -142,14 +179,18 @@ export class RolesStore {
try {
const data = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
fallbackPasswordHash: this._fallbackPasswordHash,
savedAt: new Date().toISOString(),
}
await mkdir(dirname(this._rolesFile), { recursive: true })
await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8')
} catch (err) {
this._dirty = true
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
}
// Changes during await writeFile — schedule another save.
if (this._dirty) this._markDirty()
}
// === 首次部署 bootstrap ===
@ -183,6 +224,49 @@ export class RolesStore {
return this._roles.get(empNo) || ROLES.USER
}
/**
* 个人偏好:超管/应急默认开启查看全部;显式 false 才关闭。
* admin/user 不会走到这里(resolvePermissions 里 allowToggle=false)。
*/
isViewAllSessionsEnabled(empNo) {
if (!empNo) return false
const prefs = this._prefs.get(String(empNo))
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
return !!prefs.viewAllSessions
}
return true
}
/**
* 设置「查看全部会话」偏好(调用方需校验 canToggleViewAllSessions)
* @param {string} empNo
* @param {boolean} enabled
*/
setViewAllSessions(empNo, enabled) {
const key = String(empNo || '').trim()
if (!key) throw codedError('emp_no_required')
const role = this.getRole(key)
if (!canToggleViewAllSessions(role)) {
throw codedError('forbidden_view_all_sessions')
}
const cur = { ...(this._prefs.get(key) || {}) }
// Persist explicit true/false — deleting the key would fall back to default-on.
cur.viewAllSessions = !!enabled
this._prefs.set(key, cur)
this._markDirty()
return true
}
/** 角色 + 个人偏好 → 有效权限 */
resolvePermissions(empNo, role) {
const id = empNo != null ? String(empNo) : ''
const r = role || this.getRole(id)
const allowToggle = canToggleViewAllSessions(r)
return computePermissions(r, {
viewAllSessions: allowToggle && this.isViewAllSessionsEnabled(id),
})
}
hasRole(empNo) {
return this._roles.has(empNo)
}
@ -271,6 +355,7 @@ export class RolesStore {
}
}
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._markDirty()
return true
}

View file

@ -104,7 +104,10 @@ export function identityFromAls(rolesStore) {
return {
empNo: ctx.empNo,
role,
permissions: ctx.permissions || computePermissions(role),
// Prefer live prefs — ALS identity.permissions may be frozen at WS connect.
permissions: typeof rolesStore.resolvePermissions === 'function'
? rolesStore.resolvePermissions(ctx.empNo, role)
: (ctx.permissions || computePermissions(role)),
}
}

View file

@ -0,0 +1,48 @@
$ErrorActionPreference = 'Stop'
$w = Get-Content 'C:\Users\zhout\.dsh\storages\workspace.json' -Raw | ConvertFrom-Json
$archived = [System.Collections.Generic.HashSet[string]]::new([string[]]@($w.global.archivedSessionIds))
Write-Output ("archived count=" + $archived.Count)
$liveIds = New-Object System.Collections.Generic.List[string]
Get-ChildItem 'C:\Users\zhout\.dsh\sessions' -Directory | ForEach-Object {
Get-ChildItem $_.FullName -Directory | ForEach-Object { [void]$liveIds.Add($_.Name) }
}
Write-Output ("live session dirs=" + $liveIds.Count)
$inArchived = @($liveIds | Where-Object { $archived.Contains($_) })
Write-Output ("live dirs that are archived=" + $inArchived.Count)
if ($inArchived.Count -gt 0 -and $inArchived.Count -le 20) {
$inArchived | ForEach-Object { Write-Output (" archived: " + $_) }
}
Write-Output '--- table keys ---'
foreach ($prop in $w.tables.PSObject.Properties) {
$name = $prop.Name
$val = $prop.Value
if ($null -eq $val) {
Write-Output (" {0}=null" -f $name)
continue
}
if ($val -is [System.Array] -or ($val -is [System.Collections.IList])) {
Write-Output (" {0} list count={1}" -f $name, @($val).Count)
continue
}
if ($val.PSObject -and $val.PSObject.Properties['rows']) {
Write-Output (" {0}.rows={1}" -f $name, @($val.rows).Count)
continue
}
# workspace records often keyed by id
$keys = @($val.PSObject.Properties.Name)
Write-Output (" {0} keys={1} sample={2}" -f $name, $keys.Count, (($keys | Select-Object -First 3) -join ','))
foreach ($k in ($keys | Select-Object -First 3)) {
$row = $val.$k
if ($row.sessionIds) {
Write-Output (" {0} sessionIds={1}" -f $k, @($row.sessionIds).Count)
} elseif ($row.PSObject.Properties['sessionIds']) {
Write-Output (" {0} sessionIds={1}" -f $k, @($row.sessionIds).Count)
} else {
$rowJson = ($row | ConvertTo-Json -Compress -Depth 3)
if ($rowJson.Length -gt 200) { $rowJson = $rowJson.Substring(0, 200) + '...' }
Write-Output (" {0} => {1}" -f $k, $rowJson)
}
}
}

View file

@ -0,0 +1,14 @@
$live = 'C:\Users\zhout\.dsh\sessions'
$bak = 'C:\Users\zhout\.dsh\upgrade-backup-20260914-074633\sessions'
Write-Output 'LIVE:'
Get-ChildItem $live -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count
Write-Output (" {0} => {1}" -f $_.Name, $count)
}
Write-Output 'BACKUP:'
Get-ChildItem $bak -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count
Write-Output (" {0} => {1}" -f $_.Name, $count)
}

View file

@ -0,0 +1,9 @@
$ErrorActionPreference = 'Stop'
$w = Get-Content 'C:\Users\zhout\.dsh\storages\workspace.json' -Raw | ConvertFrom-Json
foreach ($prop in $w.tables.workspaces.PSObject.Properties) {
$row = $prop.Value
Write-Output ("id={0}" -f $prop.Name)
Write-Output (" path={0}" -f $row.path)
Write-Output (" title={0}" -f $row.title)
Write-Output (" sessionIds={0}" -f @($row.sessionIds).Count)
}

View file

@ -0,0 +1,44 @@
const fs = require('fs')
const path = require('path')
const workspacePath = 'C:/Users/zhout/.dsh/storages/workspace.json'
const sessionsRoot = 'C:/Users/zhout/.dsh/sessions'
const map = {
'--C-Users-zhout-.dsh-user-workspaces-administrator--':
'C:\\Users\\zhout\\.dsh\\user-workspaces\\administrator',
'--D-project-chatgpt--': 'D:\\project\\chatgpt',
'--D-project-harness--': 'D:\\project\\harness',
}
const w = JSON.parse(fs.readFileSync(workspacePath, 'utf8'))
const pathToId = {}
for (const [id, row] of Object.entries(w.tables.workspaces)) {
pathToId[row.path] = id
}
let added = 0
for (const [folder, wsPath] of Object.entries(map)) {
const wsId = pathToId[wsPath]
if (!wsId) {
console.log('skip no workspace', wsPath)
continue
}
const proj = path.join(sessionsRoot, folder)
if (!fs.existsSync(proj)) continue
const disk = fs
.readdirSync(proj, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
const row = w.tables.workspaces[wsId]
const existing = new Set(row.sessionIds || [])
const missing = disk.filter((id) => !existing.has(id))
added += missing.length
row.sessionIds = [...missing, ...(row.sessionIds || [])]
row.updatedAt = new Date().toISOString()
console.log(wsPath, 'disk', disk.length, 'now', row.sessionIds.length, 'added', missing.length)
}
fs.copyFileSync(workspacePath, workspacePath + '.bak-before-session-restore')
fs.writeFileSync(workspacePath, JSON.stringify(w, null, 2) + '\n')
console.log('added', added)

View file

@ -0,0 +1,250 @@
/**
* Classify & prune empty/invalid DSH sessions under ~/.dsh/sessions.
*
* empty/shell — log exists but has no user/message and no turn/start
* (only session header + permission/sandbox/approval/end-seed)
* invalid — no log, 0 bytes, corrupt, non-session header
* keep — has at least one conversational event
*
* Usage:
* node prune-empty-sessions.cjs --dry-run
* node prune-empty-sessions.cjs --apply
*/
const fs = require('fs')
const path = require('path')
const { promisify } = require('util')
const { zstdDecompress } = require('zlib')
const zstdDecompressAsync = promisify(zstdDecompress)
const SESSIONS_ROOT = 'C:/Users/zhout/.dsh/sessions'
const WORKSPACE_PATH = 'C:/Users/zhout/.dsh/storages/workspace.json'
const OWNERS_PATH = 'D:/project/chatgpt/oclaw/uds-auth/session-owners.json'
const PROJCACHE = 'C:/Users/zhout/.dsh/storages/session_projcache/sessions'
const ZSTD_MAGIC = 0xfd2fb528
const apply = process.argv.includes('--apply')
/** Event types that prove the session had real conversation activity. */
const LIVE_TYPES = new Set([
'user/message',
'turn/start',
'assistant/message',
'agent/message',
'step/start',
])
function scanZstdFrames(buffer, maxFrames = Infinity) {
const frames = []
let offset = 0
while (offset < buffer.length) {
const start = offset
if (buffer.length - offset < 4) return { frames, tornStart: start }
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
throw new Error(`invalid magic at ${offset}`)
}
offset += 4
if (offset === buffer.length) return { frames, tornStart: start }
const descriptor = buffer.readUInt8(offset)
offset += 1
if ((descriptor & 0x18) !== 0) throw new Error('reserved frame-header bit')
const contentSizeFlag = descriptor >>> 6
const singleSegment = (descriptor & 0x20) !== 0
const checksum = (descriptor & 0x04) !== 0
const dictionaryFlag = descriptor & 0x03
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
const contentSizeBytes =
contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
const remainingHeaderBytes =
(singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
offset += remainingHeaderBytes
for (;;) {
if (buffer.length - offset < 3) return { frames, tornStart: start }
const blockHeader = buffer.readUIntLE(offset, 3)
offset += 3
const lastBlock = (blockHeader & 1) !== 0
const blockType = (blockHeader >>> 1) & 0x03
const blockSize = blockHeader >>> 3
if (blockType === 0x03) throw new Error('reserved block type')
const payloadBytes = blockType === 0x01 ? 1 : blockSize
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
offset += payloadBytes
if (lastBlock) break
}
if (checksum) {
if (buffer.length - offset < 4) return { frames, tornStart: start }
offset += 4
}
frames.push({ start, end: offset })
if (frames.length >= maxFrames) return { frames }
}
return { frames }
}
function findLog(dir) {
const names = fs.readdirSync(dir)
const preferred = names
.filter((n) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(n))
.sort()
return preferred[0] ? path.join(dir, preferred[0]) : null
}
function collectTypesFromText(text, types) {
for (const line of text.split(/\n/)) {
if (!line.trim()) continue
try {
const o = JSON.parse(line)
if (o && typeof o.type === 'string') types.add(o.type)
} catch {
// ignore bad lines
}
}
}
async function classify(dir) {
const log = findLog(dir)
if (!log) return { kind: 'invalid', reason: 'no-log' }
const buf = fs.readFileSync(log)
if (buf.length === 0) return { kind: 'invalid', reason: 'zero-bytes' }
const types = new Set()
try {
if (log.endsWith('.zstd')) {
const { frames, tornStart } = scanZstdFrames(buf)
if (frames.length === 0) {
return { kind: 'invalid', reason: tornStart != null ? 'torn-frame' : 'no-frames' }
}
for (const fr of frames) {
const plain = await zstdDecompressAsync(buf.subarray(fr.start, fr.end))
collectTypesFromText(plain.toString('utf8'), types)
// Early exit once we know it's live
for (const t of LIVE_TYPES) {
if (types.has(t)) {
return { kind: 'keep', reason: `has:${t}`, types: [...types] }
}
}
}
} else {
collectTypesFromText(buf.toString('utf8'), types)
}
} catch (e) {
return { kind: 'invalid', reason: `decode:${e.message}` }
}
if (!types.has('session')) {
return { kind: 'invalid', reason: 'non-session-header', types: [...types] }
}
for (const t of LIVE_TYPES) {
if (types.has(t)) return { kind: 'keep', reason: `has:${t}`, types: [...types] }
}
return {
kind: 'empty',
reason: 'no-conversation',
types: [...types],
}
}
async function main() {
const results = { invalid: [], empty: [], keep: [] }
for (const proj of fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })) {
if (!proj.isDirectory()) continue
const projDir = path.join(SESSIONS_ROOT, proj.name)
for (const sid of fs.readdirSync(projDir, { withFileTypes: true })) {
if (!sid.isDirectory()) continue
const dir = path.join(projDir, sid.name)
const c = await classify(dir)
results[c.kind].push({
id: sid.name,
proj: proj.name,
dir,
reason: c.reason,
types: c.types,
})
}
}
const byProj = {}
for (const item of [...results.empty, ...results.invalid]) {
byProj[item.proj] = (byProj[item.proj] || 0) + 1
}
console.log(
JSON.stringify(
{
mode: apply ? 'apply' : 'dry-run',
counts: {
invalid: results.invalid.length,
empty: results.empty.length,
keep: results.keep.length,
deleteTotal: results.invalid.length + results.empty.length,
},
deleteByProject: byProj,
sampleEmpty: results.empty.slice(0, 5).map((x) => ({
id: x.id,
reason: x.reason,
types: x.types,
})),
},
null,
2,
),
)
if (!apply) {
console.log('\nRe-run with --apply to delete invalid+empty and update workspace/owners.')
return
}
const toDelete = [...results.invalid, ...results.empty]
const deleteIds = new Set(toDelete.map((x) => x.id))
for (const item of toDelete) {
fs.rmSync(item.dir, { recursive: true, force: true })
const cache = path.join(PROJCACHE, `${item.id}.json`)
if (fs.existsSync(cache)) fs.rmSync(cache, { force: true })
}
if (fs.existsSync(WORKSPACE_PATH)) {
const bak = WORKSPACE_PATH + '.bak-before-prune-empty'
fs.copyFileSync(WORKSPACE_PATH, bak)
const w = JSON.parse(fs.readFileSync(WORKSPACE_PATH, 'utf8'))
if (Array.isArray(w.global?.archivedSessionIds)) {
w.global.archivedSessionIds = w.global.archivedSessionIds.filter((id) => !deleteIds.has(id))
}
for (const row of Object.values(w.tables?.workspaces || {})) {
if (!Array.isArray(row.sessionIds)) continue
const before = row.sessionIds.length
row.sessionIds = row.sessionIds.filter((id) => !deleteIds.has(id))
if (row.sessionIds.length !== before) row.updatedAt = new Date().toISOString()
}
fs.writeFileSync(WORKSPACE_PATH, JSON.stringify(w, null, 2) + '\n')
console.log('updated workspace.json; backup', bak)
}
if (fs.existsSync(OWNERS_PATH)) {
const bak = OWNERS_PATH + '.bak-before-prune-empty'
fs.copyFileSync(OWNERS_PATH, bak)
const o = JSON.parse(fs.readFileSync(OWNERS_PATH, 'utf8'))
let removed = 0
if (o.owners && typeof o.owners === 'object') {
for (const id of deleteIds) {
if (Object.prototype.hasOwnProperty.call(o.owners, id)) {
delete o.owners[id]
removed++
}
}
}
fs.writeFileSync(OWNERS_PATH, JSON.stringify(o, null, 2) + '\n')
console.log('updated session-owners.json removed', removed, 'backup', bak)
}
console.log('deleted dirs', toDelete.length)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View file

@ -0,0 +1,35 @@
$ErrorActionPreference = 'Stop'
$srcRoot = 'C:\Users\zhout\.dsh\upgrade-backup-20260914-074633\sessions'
$dstRoot = 'C:\Users\zhout\.dsh\sessions'
if (-not (Test-Path $srcRoot)) { throw "backup missing: $srcRoot" }
if (-not (Test-Path $dstRoot)) { New-Item -ItemType Directory -Path $dstRoot | Out-Null }
$copied = 0
$skipped = 0
Get-ChildItem $srcRoot -Directory | ForEach-Object {
$proj = $_.Name
$srcProj = $_.FullName
$dstProj = Join-Path $dstRoot $proj
if (-not (Test-Path $dstProj)) {
New-Item -ItemType Directory -Path $dstProj | Out-Null
}
Get-ChildItem $srcProj -Directory | ForEach-Object {
$sid = $_.Name
$dstSid = Join-Path $dstProj $sid
if (Test-Path $dstSid) {
$skipped++
return
}
Copy-Item -LiteralPath $_.FullName -Destination $dstSid -Recurse -Force
$copied++
}
}
Write-Output ("copied={0} skipped_existing={1}" -f $copied, $skipped)
Write-Output 'LIVE after restore:'
Get-ChildItem $dstRoot -Directory | ForEach-Object {
$count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count
Write-Output (" {0} => {1}" -f $_.Name, $count)
}

View file

@ -1,6 +1,7 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { createSessionAccess } from '../lib/dsh-acl.js'
import { computePermissions, RolesStore, ROLES } from '../lib/roles.js'
function makeAccess(owners = {}) {
const ownersMap = new Map(Object.entries(owners))
@ -38,25 +39,28 @@ function makeAccess(owners = {}) {
}
describe('createSessionAccess', () => {
it('super_admin and administrator see all sessions', () => {
const { canAccessSession, canSeeAll } = makeAccess({ 's-other': 'u2' })
const superAdmin = {
empNo: 'boss',
role: 'super_admin',
permissions: { canViewAllSessions: true },
}
const fallback = {
empNo: 'administrator',
role: 'fallback_admin',
permissions: { canViewAllSessions: true },
}
it('super/fallback see all by default via rolesStore prefs (default on)', () => {
const store = new RolesStore()
store._roles.set('boss', ROLES.SUPER_ADMIN)
const { canAccessSession, canSeeAll } = createSessionAccess({
sessionAcl: { getOwner: () => 'u2' },
userWorkspaces: { get: () => null, isUserPath: () => false },
getWorkspaceRoot: () => '/ws',
getWorkspaceRegistry: () => ({ list: () => [] }),
rolesStore: store,
})
const superAdmin = { empNo: 'boss', role: 'super_admin', permissions: computePermissions('super_admin') }
const fallback = { empNo: 'administrator', role: 'fallback_admin', permissions: computePermissions('fallback_admin') }
assert.equal(canSeeAll(superAdmin), true)
assert.equal(canSeeAll(fallback), true)
assert.equal(canAccessSession('s-other', superAdmin), true)
assert.equal(canAccessSession('s-other', fallback), true)
store.setViewAllSessions('boss', false)
assert.equal(canSeeAll(superAdmin), false)
assert.equal(canAccessSession('s-other', superAdmin), false)
})
it('admin and user only see owned or own-workspace sessions', () => {
it('admin and user only see owned or own-workspace sessions by default', () => {
const { canAccessSession, canSeeAll } = makeAccess({
's-owned': 'u1',
's-peer': 'u2',
@ -64,15 +68,17 @@ describe('createSessionAccess', () => {
const admin = {
empNo: 'u1',
role: 'admin',
permissions: { canViewAllSessions: false, canAccessSettings: true },
permissions: computePermissions('admin'),
}
const user = {
empNo: 'u1',
role: 'user',
permissions: { canViewAllSessions: false },
permissions: computePermissions('user'),
}
assert.equal(canSeeAll(admin), false)
assert.equal(canSeeAll(user), false)
assert.equal(admin.permissions.canToggleViewAllSessions, false)
assert.equal(user.permissions.canToggleViewAllSessions, false)
assert.equal(canAccessSession('s-owned', admin), true)
assert.equal(canAccessSession('s-in-u1', user), true)
@ -84,10 +90,106 @@ describe('createSessionAccess', () => {
assert.equal(canAccessSession('s-cwd-peer', user, { cwd: '/ws/u2/x' }), false)
})
it('admin cannot enable view-all even if preference flag is passed', () => {
const { canAccessSession, canSeeAll } = makeAccess({ 's-peer': 'u2' })
const admin = {
empNo: 'u1',
role: 'admin',
permissions: computePermissions('admin', { viewAllSessions: true }),
}
assert.equal(admin.permissions.canViewAllSessions, false)
assert.equal(admin.permissions.canToggleViewAllSessions, false)
assert.equal(canSeeAll(admin), false)
assert.equal(canAccessSession('s-peer', admin), false)
})
it('missing owner does not deny when cwd is under user path', () => {
const { canAccessSession } = makeAccess({})
const user = { empNo: 'u1', role: 'user', permissions: { canViewAllSessions: false } }
const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user') }
assert.equal(canAccessSession('legacy', user, { cwd: '/ws/u1' }), true)
assert.equal(canAccessSession('legacy-other', user, { cwd: '/ws/u2' }), false)
})
it('admin can see unowned channel/system sessions outside user-workspaces', () => {
const { canAccessSession, isVisibleWorkspace } = makeAccess({})
const admin = {
empNo: 'u1',
role: 'admin',
permissions: computePermissions('admin'),
}
const user = {
empNo: 'u1',
role: 'user',
permissions: computePermissions('user'),
}
assert.equal(canAccessSession('ch-1', admin, { cwd: '/bots/whatsapp' }), true)
assert.equal(canAccessSession('ch-1', user, { cwd: '/bots/whatsapp' }), false)
assert.equal(canAccessSession('s-peer', admin, { cwd: '/ws/u2/x' }), false)
const access = makeAccess({ 'ch-owned': 'u2' })
assert.equal(access.canAccessSession('ch-owned', admin, { cwd: '/bots/wa' }), false)
assert.equal(isVisibleWorkspace(admin, { id: 'bot-ws', path: '/bots/whatsapp' }), true)
assert.equal(isVisibleWorkspace(user, { id: 'bot-ws', path: '/bots/whatsapp' }), false)
})
it('live rolesStore prefs override stale identity.permissions', () => {
const store = new RolesStore()
store._roles.set('boss', ROLES.SUPER_ADMIN)
store.setViewAllSessions('boss', false)
const ownersMap = new Map([['s-peer', 'u2']])
const access = createSessionAccess({
sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null },
userWorkspaces: {
get: () => null,
isUserPath: () => false,
},
getWorkspaceRoot: () => '/ws',
getWorkspaceRegistry: () => ({ list: () => [] }),
rolesStore: store,
})
const stale = {
empNo: 'boss',
role: 'super_admin',
permissions: computePermissions('super_admin', { viewAllSessions: true }),
}
assert.equal(access.canSeeAll(stale), false)
assert.equal(access.canAccessSession('s-peer', stale), false)
store.setViewAllSessions('boss', true)
assert.equal(access.canSeeAll(stale), true)
assert.equal(access.canAccessSession('s-peer', stale), true)
})
})
describe('RolesStore view-all prefs', () => {
it('defaults on for super_admin and can be turned off', () => {
const store = new RolesStore()
store._roles.set('boss', ROLES.SUPER_ADMIN)
store._roles.set('op', ROLES.ADMIN)
assert.equal(store.isViewAllSessionsEnabled('boss'), true)
assert.equal(store.resolvePermissions('boss').canViewAllSessions, true)
assert.equal(store.resolvePermissions('boss').canToggleViewAllSessions, true)
store.setViewAllSessions('boss', false)
assert.equal(store.isViewAllSessionsEnabled('boss'), false)
assert.equal(store.resolvePermissions('boss').canViewAllSessions, false)
store.setViewAllSessions('boss', true)
assert.equal(store.resolvePermissions('boss').canViewAllSessions, true)
assert.throws(() => store.setViewAllSessions('op', true), (err) => err.code === 'forbidden_view_all_sessions')
store._prefs.set('op', { viewAllSessions: true })
assert.equal(store.resolvePermissions('op').canViewAllSessions, false)
assert.equal(store.resolvePermissions('op').canToggleViewAllSessions, false)
})
it('rejects view-all toggle for ordinary users and admins', () => {
const store = new RolesStore()
store._roles.set('u1', ROLES.USER)
store._roles.set('a1', ROLES.ADMIN)
assert.throws(() => store.setViewAllSessions('u1', true), (err) => err.code === 'forbidden_view_all_sessions')
assert.throws(() => store.setViewAllSessions('a1', true), (err) => err.code === 'forbidden_view_all_sessions')
})
})