Make uds-auth optional: standalone local mode for cron.

Without udsAuth, trusted HTTP uses a synthetic __local__ viewer that sees all jobs; with uds-auth, keep multi-user empNo isolation and admin folders. Document that IM channels are not default UDS accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 17:39:54 +08:00
parent 1c504eb1fd
commit 933bad9201
8 changed files with 132 additions and 27 deletions

View file

@ -705,6 +705,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const searchRef = useRef(null)
const needle = query.trim().toLowerCase()
const canViewAll = !!viewer?.canViewAll
const multiUser = viewer?.mode === 'multi'
const visibleJobs = !needle ? jobs : jobs.filter((job) => {
const hay = [
job.name,
@ -910,7 +911,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('div', { className: skin.list },
jobs.length === 0 ? h('p', { className: skin.empty }, error || t('emptyJobs'))
: visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty'))
: (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))),
: (multiUser && canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))),
),
)
}
@ -932,7 +933,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
function CwdField({ t, form, setForm, workspaces, viewer }) {
const rows = Array.isArray(workspaces) ? workspaces : []
const mode = cwdSelectValue(form.cwd, rows)
const allowCustom = !!viewer?.canViewAll
const allowCustom = viewer?.mode === 'multi' ? !!viewer?.canViewAll : true
return h('label', null, t('cwd'),
h('select', {
value: mode,

View file

@ -29,6 +29,8 @@ import {
claimUnassignedForViewer,
filterJobsForIdentity,
filterRunsForJobs,
isMultiUserIdentity,
localIdentity,
migrateJobOwners,
normalizeOwnerEmpNo,
UNASSIGNED_OWNER,
@ -151,16 +153,16 @@ async function requireIdentity(request, write, getUdsAuth) {
return null
}
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
// Soft dep: without uds-auth, run in standalone single-tenant mode.
if (!uds || typeof uds.resolveRequestIdentity !== 'function') {
write(503, { ok: false, error: 'auth_unavailable', message: 'uds-auth 未就绪,无法使用定时任务' })
return null
return localIdentity()
}
const identity = await resolveBrowserIdentity(request, getUdsAuth)
if (!identity?.empNo) {
write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' })
return null
}
return identity
return { ...identity, mode: 'multi' }
}
function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) {
@ -311,14 +313,17 @@ export function createHostService(options = {}) {
const t = now()
// Ignore client-supplied ids on create — otherwise POST/tools can overwrite.
const { id: _ignoredId, ownerEmpNo: _ignoreOwner, ...safeInput } = input && typeof input === 'object' ? input : {}
let ownerIdentity = identity?.empNo ? identity : inferIdentityFromJobInput(safeInput, getUdsAuth)
if (ownerIdentity?.empNo) {
let ownerIdentity = isMultiUserIdentity(identity)
? identity
: inferIdentityFromJobInput(safeInput, getUdsAuth)
if (isMultiUserIdentity(ownerIdentity)) {
safeInput.ownerEmpNo = ownerIdentity.empNo
safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo })
} else if (safeInput.ownerEmpNo) {
safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo)
} else {
// Standalone / no inferred owner: leave unassigned (visible to everyone in local mode).
safeInput.ownerEmpNo = UNASSIGNED_OWNER
}
let created
@ -803,7 +808,7 @@ export function createHostService(options = {}) {
} catch (error) {
const code = error && error.code
if (code === 'NOT_FOUND') return write(404, { ok: false, error: error.message })
if (code === 'LOGIN_REQUIRED') return write(401, { ok: false, error: 'login_required', message: error.message })
write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' })
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD') {
return write(400, { ok: false, error: error.message, code })
}

4
lib/index.d.ts vendored
View file

@ -17,6 +17,10 @@ export function createHostService(options?: object): object
export function makeLiveSessionPort(ctx: object): object
export function resolveSessionPlacement(ctx: object, job?: object, deps?: object): { cwd: string, workspace: object | null }
export const UNASSIGNED_OWNER: '__unassigned__'
export const LOCAL_EMP_NO: '__local__'
export function isMultiUserIdentity(identity: object | null | undefined): boolean
export function localIdentity(overrides?: object): object
export function claimUnassignedForViewer(state: object, identity: object, deps?: object): { state: object, changed: boolean }
export function jobVisibleToIdentity(job: object, identity: object): boolean
export function canViewAllJobs(identity: object): boolean
export function assertCanAccessJob(job: object, identity: object): object

View file

@ -51,8 +51,12 @@ export { normalizeJobModel } from './store.js'
export {
assertCanAccessJob,
canViewAllJobs,
claimUnassignedForViewer,
filterJobsForIdentity,
isMultiUserIdentity,
jobVisibleToIdentity,
LOCAL_EMP_NO,
localIdentity,
migrateJobOwners,
UNASSIGNED_OWNER,
viewerPayload,

View file

@ -1,29 +1,48 @@
/**
* Job ownership helpers for per-user cron isolation (aligns with uds-auth roles).
*
* Two modes:
* - standalone (no uds-auth): identity empNo is LOCAL_EMP_NO → everyone sees all jobs
* - multi-user (uds-auth present): filter/claim by real empNo
*/
export const UNASSIGNED_OWNER = '__unassigned__'
export const LOCAL_EMP_NO = '__local__'
export function normalizeOwnerEmpNo(value) {
const empNo = String(value || '').trim()
return empNo || UNASSIGNED_OWNER
}
export function canViewAllJobs(identity) {
return !!identity?.permissions?.canViewAllSessions
/** True when uds-auth supplied a real user identity (not standalone local). */
export function isMultiUserIdentity(identity) {
const empNo = String(identity?.empNo || '').trim()
return !!empNo && empNo !== LOCAL_EMP_NO
}
function empNoFromUserWorkspacePath(cwd) {
const norm = String(cwd || '').replace(/\\/g, '/')
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
return match ? decodeURIComponent(match[1]) : null
export function localIdentity(overrides = {}) {
return {
empNo: LOCAL_EMP_NO,
role: 'local',
displayName: 'local',
permissions: { canViewAllSessions: true },
workspacePath: null,
mode: 'local',
...overrides,
}
}
export function canViewAllJobs(identity) {
if (!isMultiUserIdentity(identity)) return true
return !!identity?.permissions?.canViewAllSessions
}
export function jobVisibleToIdentity(job, identity) {
if (!identity?.empNo) return false
if (canViewAllJobs(identity)) return true
// Standalone / local: no empNo isolation.
if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) return true
const owner = normalizeOwnerEmpNo(job?.ownerEmpNo)
// Unclaimed jobs are admin-only (super_admin / fallback_admin via canViewAll).
// Unclaimed jobs are admin-only in multi-user mode.
if (owner === UNASSIGNED_OWNER) return false
return owner === String(identity.empNo)
}
@ -50,7 +69,7 @@ export function assertCanAccessJob(job, identity) {
export function filterJobsForIdentity(jobs, identity) {
const list = Array.isArray(jobs) ? jobs : []
if (!identity?.empNo) return []
if (canViewAllJobs(identity)) return [...list]
if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) return [...list]
return list.filter((job) => jobVisibleToIdentity(job, identity))
}
@ -109,21 +128,23 @@ export function migrateJobOwners(state, deps = {}) {
export function viewerPayload(identity) {
if (!identity?.empNo) return null
const multi = isMultiUserIdentity(identity)
return {
empNo: identity.empNo,
role: identity.role || 'user',
role: identity.role || (multi ? 'user' : 'local'),
displayName: identity.displayName || identity.empNo,
canViewAll: canViewAllJobs(identity),
workspacePath: identity.workspacePath || null,
mode: multi ? 'multi' : 'local',
}
}
/**
* Claim unassigned jobs that clearly belong to the viewer (origin session / cwd).
* Returns { state, changed }.
* No-op in standalone / canViewAll. Returns { state, changed }.
*/
export function claimUnassignedForViewer(state, identity, deps = {}) {
if (!identity?.empNo || canViewAllJobs(identity)) {
if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) {
return { state, changed: false }
}
const jobs = Array.isArray(state?.jobs) ? state.jobs : []
@ -134,14 +155,14 @@ export function claimUnassignedForViewer(state, identity, deps = {}) {
? job.origin.sessionId.trim()
: ''
let mine = false
// Only claim when evidence ties the job to this viewer. Remaining unassigned
// jobs stay admin-only until a super/fallback admin reassigns them.
// Only claim when evidence ties the job to this viewer.
if (sessionId && typeof deps.getSessionOwner === 'function') {
mine = deps.getSessionOwner(sessionId) === identity.empNo
}
if (!mine && job.cwd) {
const pathOwner = empNoFromUserWorkspacePath(job.cwd)
if (pathOwner === identity.empNo) mine = true
const norm = String(job.cwd).replace(/\\/g, '/')
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
mine = !!(match && decodeURIComponent(match[1]) === identity.empNo)
}
if (!mine) return job
changed = true