Isolate scheduled tasks by empNo, aligned with uds-auth workspaces.

Super/fallback admins see all jobs grouped by user folder; admin/user only see their own. Stamp ownership on create, filter HTTP/tools, and prefer the owner's provisioned cwd on fire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 15:49:54 +08:00
parent 0ffc48b43a
commit 7d27179e51
12 changed files with 785 additions and 141 deletions

View file

@ -17,6 +17,17 @@ dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron"
# restart dsh web
```
## Multi-user isolation (with uds-auth)
Requires **uds-auth** in the same profile. Jobs carry `ownerEmpNo`; visibility matches workspace ACL:
| Role | Sees |
|------|------|
| `super_admin` / `fallback_admin` | All users’ jobs (sidebar groups by empNo folder) and their run sessions |
| `admin` / `user` | Only own jobs and runs |
Create stamps the logged-in empNo. Legacy jobs without owner migrate to `__unassigned__` (super-only). Fire cwd prefers the owner’s provisioned workspace under `user-workspaces/<empNo>`.
## Delivery defaults
| Created from | Default delivery | Effective-session mirror |

View file

@ -180,3 +180,18 @@
- **入口位置**:无官方「新会话下方」slot;注入必须紧挨该按钮。仅 footer 不满足需求。
- **Host 必须在跑**。写进本 PRD 与 README,避免被当成 bug。
- 提示词按不可信内容包裹,降低提示注入把定时通道变成越权入口的风险。
## 10. 多用户隔离(对齐 uds-auth 工作区)
与侧栏工作区同一套角色:
| 角色 | 定时任务 / 任务会话 |
|------|---------------------|
| `super_admin` / `fallback_admin` | 按用户文件夹看到全部;可改派 `ownerEmpNo` |
| `admin` / `user` | 仅 `ownerEmpNo === 自己` |
- Job 字段:`ownerEmpNo`、`ownerDisplayName`(创建时服务端盖章,客户端不可伪造)。
- 存量无归属:IM origin → `__unassigned__`;Web 可尝试 `sessionAcl` 推断;否则 `__unassigned__`(仅超管可见)。
- 依赖 Cordis 服务 `udsAuth`;未就绪时 HTTP API 返回 503,不回退为全员可见。
- 调度器仍扫描全库 enabled jobs;归属只约束可见与写权限。
- oclaw `scheduled_job`(管理后台)为另一套系统,本 PRD 不覆盖。

View file

@ -191,6 +191,7 @@ window.__ModuleLoader__.load({
catalog: { groups: [], current: null },
presets: { items: [], current: null },
imCatalog: { available: true, options: [], loading: false },
viewer: null,
}
function jobStamp(rows) {
@ -332,7 +333,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
emptyJobs: '还没有定时任务。点右上角 + 新建。', unassignedOwner: '未归属', ownerClaim: '改派归属', next: '下次', last: '上次',
expandRuns: '展开记录', collapseRuns: '收起记录',
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停',
@ -373,7 +374,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
create: 'Create', pause: 'Pause', resume: 'Resume', runNow: 'Run now', remove: 'Delete',
emptyJobs: 'No jobs yet. Use + to create one.', next: 'Next', last: 'Last',
emptyJobs: 'No jobs yet. Use + to create one.', unassignedOwner: 'Unassigned', ownerClaim: 'Reassign owner', next: 'Next', last: 'Last',
expandRuns: 'Show runs', collapseRuns: 'Hide runs',
search: 'Search', searchPlaceholder: 'Search jobs', searchClear: 'Clear search', searchEmpty: 'No matching jobs.',
paused: 'Paused',
@ -696,12 +697,13 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return ''
}
function JobList({ t, jobs, runs, selection, expanded, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
function JobList({ t, jobs, runs, selection, expanded, viewer, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
const skin = workspaceSkin()
const [query, setQuery] = useState('')
const [searchOn, setSearchOn] = useState(false)
const searchRef = useRef(null)
const needle = query.trim().toLowerCase()
const canViewAll = !!viewer?.canViewAll
const visibleJobs = !needle ? jobs : jobs.filter((job) => {
const hay = [
job.name,
@ -710,10 +712,140 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
job.schedule?.at,
job.provider,
job.model,
job.ownerEmpNo,
job.ownerDisplayName,
...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)),
].join(' ').toLowerCase()
return hay.includes(needle)
})
function ownerKey(job) {
return String(job?.ownerEmpNo || '__unassigned__')
}
function ownerLabel(job) {
const key = ownerKey(job)
if (key === '__unassigned__') return t('unassignedOwner')
const name = String(job?.ownerDisplayName || '').trim()
return name && name !== key ? `${key} · ${name}` : key
}
function renderJobGroup(job) {
const open = expanded[job.id] === true || !!needle
const paused = job.enabled === false
const jobRuns = runs.filter((run) => run.jobId === job.id)
const selectedJob = selection?.type === 'job' && selection.jobId === job.id
const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id)
return h('div', { className: skin.group, key: job.id },
h('div', {
className: skin.project,
role: 'treeitem',
'aria-expanded': open,
'data-on': selectedJob ? 'true' : 'false',
'data-paused': paused ? 'true' : 'false',
onClick: () => onSelectJob(job.id),
},
h('span', {
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
'data-on': open && containsCurrent ? 'true' : 'false',
}, folderIcon(open)),
h('button', {
type: 'button',
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
title: open ? t('collapseRuns') : t('expandRuns'),
onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) },
},
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
h('button', {
type: 'button',
className: skin.rowIcon,
title: paused ? t('resume') : t('pause'),
onClick: () => onToggle(job),
}, pauseIcon() || (paused ? '▶' : '⏸')),
h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) },
trashIcon() || '×'),
),
),
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
h('div', { className: 'dsh-ct-runsInner' },
jobRuns.map((run) => {
const selectedRun = selection?.type === 'run' && selection.runId === run.id
return h('div', {
key: run.id,
className: skin.session,
role: 'treeitem',
'data-on': selectedRun ? 'true' : 'false',
onClick: () => onSelectRun(job.id, run),
},
h('span', { className: skin.slot }, sessionIcon() || '·'),
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
)
}),
),
),
)
}
function renderGrouped() {
const groups = new Map()
for (const job of visibleJobs) {
const key = ownerKey(job)
if (!groups.has(key)) groups.set(key, { key, label: ownerLabel(job), jobs: [] })
groups.get(key).jobs.push(job)
}
const ordered = [...groups.values()].sort((a, b) => {
if (a.key === '__unassigned__') return 1
if (b.key === '__unassigned__') return -1
return String(a.label).localeCompare(String(b.label), 'zh')
})
return ordered.map((group) => {
const folderId = `owner:${group.key}`
const open = expanded[folderId] !== false || !!needle
const containsCurrent = group.jobs.some((job) => (
(selection?.type === 'job' && selection.jobId === job.id)
|| (selection?.type === 'run' && selection.jobId === job.id)
))
return h('div', { className: skin.group, key: folderId },
h('div', {
className: skin.project,
role: 'treeitem',
'aria-expanded': open,
'data-on': containsCurrent ? 'true' : 'false',
onClick: () => onToggleGroup(folderId),
},
h('span', {
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
'data-on': open && containsCurrent ? 'true' : 'false',
}, folderIcon(open)),
h('button', {
type: 'button',
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
title: open ? t('collapseRuns') : t('expandRuns'),
onClick: (e) => { e.stopPropagation(); onToggleGroup(folderId) },
},
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, `${group.label} · ${group.jobs.length}`)),
),
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
h('div', { className: 'dsh-ct-runsInner' },
group.jobs.map((job) => renderJobGroup(job)),
),
),
)
})
}
return h(React.Fragment, null,
h('div', { className: skin.sectionHeader },
h('span', { className: joinClass(skin.sectionLabel, searchOn ? skin.sectionLabelHidden : '') }, t('title')),
@ -768,71 +900,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 }, t('emptyJobs'))
: visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty'))
: visibleJobs.map((job) => {
const open = expanded[job.id] === true || !!needle
const paused = job.enabled === false
const jobRuns = runs.filter((run) => run.jobId === job.id)
const selectedJob = selection?.type === 'job' && selection.jobId === job.id
const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id)
return h('div', { className: skin.group, key: job.id },
h('div', {
className: skin.project,
role: 'treeitem',
'aria-expanded': open,
'data-on': selectedJob ? 'true' : 'false',
'data-paused': paused ? 'true' : 'false',
onClick: () => onSelectJob(job.id),
},
h('span', {
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
'data-on': open && containsCurrent ? 'true' : 'false',
}, folderIcon(open)),
h('button', {
type: 'button',
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
title: open ? t('collapseRuns') : t('expandRuns'),
onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) },
},
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
h('button', {
type: 'button',
className: skin.rowIcon,
title: paused ? t('resume') : t('pause'),
onClick: () => onToggle(job),
}, pauseIcon() || (paused ? '▶' : '⏸')),
h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) },
trashIcon() || '×'),
),
),
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
h('div', { className: 'dsh-ct-runsInner' },
jobRuns.map((run) => {
const selectedRun = selection?.type === 'run' && selection.runId === run.id
return h('div', {
role: 'treeitem',
'aria-selected': selectedRun,
key: run.id,
className: joinClass(skin.session, selectedRun ? skin.sessionOn : ''),
'data-on': selectedRun ? 'true' : 'false',
onClick: () => onSelectRun(job.id, run),
},
h('span', { className: skin.slot }),
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
)
}),
),
),
)
}),
: (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))),
),
)
}
@ -851,9 +919,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return '__custom__'
}
function CwdField({ t, form, setForm, workspaces }) {
function CwdField({ t, form, setForm, workspaces, viewer }) {
const rows = Array.isArray(workspaces) ? workspaces : []
const mode = cwdSelectValue(form.cwd, rows)
const allowCustom = !!viewer?.canViewAll
return h('label', null, t('cwd'),
h('select', {
value: mode,
@ -869,9 +938,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
},
h('option', { value: '' }, t('cwdRecent')),
...rows.map((row) => h('option', { value: row.path, key: row.id || row.path }, `${row.title} — ${row.path}`)),
h('option', { value: '__custom__' }, t('cwdCustom')),
...(allowCustom ? [h('option', { value: '__custom__' }, t('cwdCustom'))] : []),
),
mode === '__custom__'
allowCustom && mode === '__custom__'
? h('input', {
placeholder: t('cwdPlaceholder'),
value: form.cwd,
@ -1041,7 +1110,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('label', null, t('scheduleTz'), h('input', { value: form.timezone, onChange: (e) => setForm({ ...form, timezone: e.target.value }) })),
h('label', null, t('timeout'), h('input', { type: 'number', min: 1, max: 240, value: form.timeoutMinutes, onChange: (e) => setForm({ ...form, timeoutMinutes: Number(e.target.value) }) })),
),
h(CwdField, { t, form, setForm, workspaces }),
h(CwdField, { t, form, setForm, workspaces, viewer }),
h(ModelField, { t, form, setForm, catalog }),
h(PresetField, { t, form, setForm, presets }),
h('label', null, t('delivery'),
@ -1072,6 +1141,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
function CronApp({ t, faces }) {
const [jobs, setJobs] = useState(() => listSnapshot.jobs)
const [viewer, setViewer] = useState(() => listSnapshot.viewer)
const [runs, setRuns] = useState(() => listSnapshot.runs)
const [selection, setSelection] = useState({ type: 'new' })
const [expanded, setExpanded] = useState({})
@ -1140,11 +1210,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
const nextJobs = jobBody.jobs || []
const nextRuns = histBody.runs || []
const nextWorkspaces = wsBody.workspaces || []
const nextViewer = jobBody.viewer || wsBody.viewer || histBody.viewer || null
const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null }
const nextPresets = { items: presetBody.items || [], current: presetBody.current || null }
if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) {
listSnapshot.jobs = nextJobs
listSnapshot.viewer = nextViewer
setJobs(nextJobs)
setViewer(nextViewer)
}
if (runStamp(listSnapshot.runs) !== runStamp(nextRuns)) {
listSnapshot.runs = nextRuns
@ -1348,7 +1421,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return h(React.Fragment, null,
h(JobList, {
t, jobs, runs, selection, expanded,
t, jobs, runs, selection, viewer, expanded,
onSelectJob: selectJob,
onSelectRun: selectRun,
onNew: selectNew,

View file

@ -56,6 +56,8 @@ export function publicJob(job) {
model: job.model || '',
reasoningEffort: job.reasoningEffort || '',
agentPreset: job.agentPreset || '',
ownerEmpNo: job.ownerEmpNo || '',
ownerDisplayName: job.ownerDisplayName || '',
delivery: job.delivery && job.delivery.kind === 'im'
? {
kind: 'im',

View file

@ -23,6 +23,16 @@ import {
storePath,
upsertJob,
} from './store.js'
import {
assertCanAccessJob,
canViewAllJobs,
filterJobsForIdentity,
filterRunsForJobs,
migrateJobOwners,
normalizeOwnerEmpNo,
UNASSIGNED_OWNER,
viewerPayload,
} from './ownership.js'
export const PLUGIN_NAME = 'dsh-ops-cron'
export const API_PREFIX = '/dsh-ops-cron'
@ -68,24 +78,68 @@ function parseCookieHeader(header, name) {
return null
}
/** Browser UDS / fallback login cookies (validated more strictly by uds-auth when present). */
function browserEmpNo(request) {
const cookie = request?.headers?.cookie || ''
return parseCookieHeader(cookie, 'PORTALSSOUser')
|| parseCookieHeader(cookie, 'ZTEDPGSSOUser')
|| parseCookieHeader(cookie, 'UDS_FALLBACK_USER')
|| parseCookieHeader(cookie, 'UDS_FALLBACK_UI')
}
function requireBrowserLogin(request, write) {
/**
* Resolve browser identity via uds-auth. Returns null when missing/unavailable.
* @param {object} request
* @param {() => object|undefined} getUdsAuth
*/
async function resolveBrowserIdentity(request, getUdsAuth) {
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
if (!uds || typeof uds.resolveRequestIdentity !== 'function') return null
try {
const identity = await uds.resolveRequestIdentity(request)
if (!identity?.empNo) return null
const workspacePath = typeof uds.getProvisionedWorkspacePath === 'function'
? uds.getProvisionedWorkspacePath(identity.empNo)
: null
return { ...identity, workspacePath: workspacePath || identity.workspacePath || null }
} catch {
return null
}
}
/**
* @returns {Promise<object|null>} identity or null after writing error response
*/
async function requireIdentity(request, write, getUdsAuth) {
if (!isTrustedApiRequest(request)) {
write(403, { ok: false, error: 'forbidden' })
return false
return null
}
if (!browserEmpNo(request)) {
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
if (!uds || typeof uds.resolveRequestIdentity !== 'function') {
write(503, { ok: false, error: 'auth_unavailable', message: 'uds-auth 未就绪,无法使用定时任务' })
return null
}
const identity = await resolveBrowserIdentity(request, getUdsAuth)
if (!identity?.empNo) {
write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' })
return false
return null
}
return true
return identity
}
function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) {
const raw = typeof cwd === 'string' ? cwd.trim() : ''
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
const owner = forOwnerEmpNo || identity?.empNo
const ownerPath = owner && uds?.getProvisionedWorkspacePath
? uds.getProvisionedWorkspacePath(owner)
: null
if (!raw) return ownerPath || ''
if (canViewAllJobs(identity)) return raw
if (owner && uds?.isUserPath?.(owner, raw)) return raw
if (ownerPath) return ownerPath
return ''
}
function isTrustedApiRequest(request) {
@ -162,6 +216,7 @@ export function createHostService(options = {}) {
const sessionPort = options.sessionPort || null
const getDshIm = typeof options.getDshIm === 'function' ? options.getDshIm : () => undefined
const getAgents = typeof options.getAgents === 'function' ? options.getAgents : () => undefined
const getUdsAuth = typeof options.getUdsAuth === 'function' ? options.getUdsAuth : () => undefined
const logger = options.logger || console
const tickIntervalMs = Number(options.tickIntervalMs) > 0 ? Number(options.tickIntervalMs) : 15_000
let timer = null
@ -215,10 +270,19 @@ export function createHostService(options = {}) {
return { job: jobView(job), run: runView(run), decision: claimedDecision }
}
async function createJob(input) {
async function createJob(input, identity = null) {
const t = now()
// Ignore client-supplied ids on create — otherwise POST/tools can overwrite.
const { id: _ignoredId, ...safeInput } = input && typeof input === 'object' ? input : {}
const { id: _ignoredId, ownerEmpNo: _ignoreOwner, ...safeInput } = input && typeof input === 'object' ? input : {}
if (identity?.empNo) {
safeInput.ownerEmpNo = identity.empNo
safeInput.ownerDisplayName = identity.displayName || identity.empNo
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, identity, getUdsAuth, { forOwnerEmpNo: identity.empNo })
} else if (safeInput.ownerEmpNo) {
safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo)
} else {
safeInput.ownerEmpNo = UNASSIGNED_OWNER
}
let created
await withState((current) => {
created = createJobRecord(safeInput, current, t)
@ -227,7 +291,7 @@ export function createHostService(options = {}) {
return jobView(created)
}
async function updateJob(jobId, patch) {
async function updateJob(jobId, patch, identity = null) {
const t = now()
const state = await withState((current) => {
const job = getJob(current, jobId)
@ -236,6 +300,7 @@ export function createHostService(options = {}) {
error.code = 'NOT_FOUND'
throw error
}
if (identity) patch = { ...patch, _identity: identity }
const nextInput = {
id: job.id,
name: patch.name !== undefined ? patch.name : job.name,
@ -254,6 +319,26 @@ export function createHostService(options = {}) {
origin: patch.origin !== undefined
? (normalizeOrigin(patch.origin) || undefined)
: job.origin,
ownerEmpNo: job.ownerEmpNo || UNASSIGNED_OWNER,
ownerDisplayName: job.ownerDisplayName || '',
}
if (patch._identity) {
nextInput.cwd = sanitizeJobCwd(
nextInput.cwd,
patch._identity,
getUdsAuth,
{ forOwnerEmpNo: nextInput.ownerEmpNo },
)
}
if (patch.ownerEmpNo !== undefined && canViewAllJobs(patch._identity)) {
nextInput.ownerEmpNo = normalizeOwnerEmpNo(patch.ownerEmpNo)
if (patch.ownerDisplayName !== undefined) {
nextInput.ownerDisplayName = String(patch.ownerDisplayName || '').trim().slice(0, 80)
} else if (nextInput.ownerEmpNo !== job.ownerEmpNo) {
nextInput.ownerDisplayName = nextInput.ownerEmpNo === UNASSIGNED_OWNER
? ''
: (patch.ownerDisplayName || nextInput.ownerEmpNo)
}
}
const record = createJobRecord(nextInput, current, t)
record.createdAt = job.createdAt
@ -268,8 +353,8 @@ export function createHostService(options = {}) {
return jobView(getJob(state, jobId))
}
async function pauseJob(jobId, enabled) {
return updateJob(jobId, { enabled })
async function pauseJob(jobId, enabled, identity = null) {
return updateJob(jobId, { enabled }, identity)
}
async function deleteJob(jobId) {
@ -388,8 +473,20 @@ export function createHostService(options = {}) {
return hidden
}
async function migrateOwnersIfNeeded() {
const uds = getUdsAuth()
await withState((current) => {
const { state, changed } = migrateJobOwners(current, {
getSessionOwner: (sessionId) => uds?.getSessionOwner?.(sessionId) || null,
})
return changed ? state : current
})
}
async function recover() {
await mkdir(join(dshHome(), 'ops-cron'), { recursive: true })
await migrateOwnersIfNeeded()
const t = now()
await withState((current) => {
let next = interruptActiveRuns(current, t)
@ -430,14 +527,16 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/settings` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const state = await snapshot()
write(200, { ok: true, settings: state.settings })
return
}
if (path === `${API_PREFIX}/settings` && method === 'PUT') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const body = await readJsonBody(req)
const settings = await updateSettings(body)
write(200, { ok: true, settings })
@ -445,7 +544,8 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/models` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const catalog = typeof sessionPort?.listModels === 'function'
? await sessionPort.listModels()
: { groups: [], current: null }
@ -454,7 +554,8 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/presets` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const catalog = typeof sessionPort?.listPresets === 'function'
? await sessionPort.listPresets()
: { items: [], current: null }
@ -463,16 +564,27 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
let workspaces = typeof sessionPort?.listWorkspaces === 'function'
? await sessionPort.listWorkspaces()
: []
write(200, { ok: true, workspaces: Array.isArray(workspaces) ? workspaces : [] })
if (!Array.isArray(workspaces)) workspaces = []
const uds = getUdsAuth()
if (!canViewAllJobs(identity) && uds?.isUserPath) {
workspaces = workspaces.filter((row) => uds.isUserPath(identity.empNo, row?.path))
}
if (!canViewAllJobs(identity) && workspaces.length === 0) {
const pathOnly = identity.workspacePath || uds?.getProvisionedWorkspacePath?.(identity.empNo)
if (pathOnly) workspaces = [{ id: null, path: pathOnly, name: identity.displayName || identity.empNo }]
}
write(200, { ok: true, workspaces, viewer: viewerPayload(identity) })
return
}
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const dshIm = getDshIm()
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
write(200, {
@ -502,16 +614,23 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const state = await snapshot()
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
const jobs = filterJobsForIdentity(listJobs(state), identity).map(jobView)
write(200, {
ok: true,
jobs,
viewer: viewerPayload(identity),
})
return
}
if (path === `${API_PREFIX}/jobs` && method === 'POST') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const body = await readJsonBody(req)
const job = await createJob(body)
const job = await createJob(body, identity)
write(200, { ok: true, job })
return
}
@ -520,18 +639,18 @@ export function createHostService(options = {}) {
if (jobMatch) {
const jobId = decodeURIComponent(jobMatch[1])
const rest = jobMatch[2] || ''
if (method === 'GET' && !rest) {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const state = await snapshot()
const job = getJob(state, jobId)
if (!job) return write(404, { ok: false, error: 'job not found' })
write(200, { ok: true, job: jobView(job) })
const existing = getJob(state, jobId)
assertCanAccessJob(existing, identity)
if (method === 'GET' && !rest) {
write(200, { ok: true, job: jobView(existing) })
return
}
if (!requireBrowserLogin(req, write)) return
if (method === 'PATCH' && !rest) {
const body = await readJsonBody(req)
const job = await updateJob(jobId, body)
const job = await updateJob(jobId, body, identity)
write(200, { ok: true, job })
return
}
@ -546,12 +665,12 @@ export function createHostService(options = {}) {
return
}
if (method === 'POST' && rest === '/pause') {
const job = await pauseJob(jobId, false)
const job = await pauseJob(jobId, false, identity)
write(200, { ok: true, job })
return
}
if (method === 'POST' && rest === '/resume') {
const job = await pauseJob(jobId, true)
const job = await pauseJob(jobId, true, identity)
write(200, { ok: true, job })
return
}
@ -559,11 +678,13 @@ export function createHostService(options = {}) {
const openMatch = path.match(new RegExp(`^${API_PREFIX}/runs/([^/]+)/open$`))
if (openMatch && method === 'POST') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const runId = decodeURIComponent(openMatch[1])
const state = await snapshot()
const run = (state.runs || []).find((row) => row.id === runId)
if (!run?.sessionId) return write(404, { ok: false, error: 'run not found' })
assertCanAccessJob(getJob(state, run.jobId), identity)
let unarchived = false
if (typeof sessionPort?.revealSession === 'function') {
unarchived = await sessionPort.revealSession(run.sessionId)
@ -574,7 +695,8 @@ export function createHostService(options = {}) {
const adoptMatch = path.match(new RegExp(`^${API_PREFIX}/sessions/([^/]+)/adopt$`))
if (adoptMatch && method === 'POST') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const sessionId = decodeURIComponent(adoptMatch[1])
if (!sessionId) return write(400, { ok: false, error: 'sessionId required' })
if (typeof sessionPort?.adoptSession !== 'function') {
@ -586,22 +708,30 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/conceal` && method === 'POST') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const hidden = await concealKnownSessions()
write(200, { ok: true, hidden })
return
}
if (path === `${API_PREFIX}/history` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const state = await snapshot()
const jobId = url.searchParams.get('jobId') || undefined
write(200, { ok: true, runs: listHistory(state, jobId).map(runView) })
if (jobId) {
assertCanAccessJob(getJob(state, jobId), identity)
}
const visibleJobs = filterJobsForIdentity(listJobs(state), identity)
const runs = filterRunsForJobs(listHistory(state, jobId), visibleJobs).map(runView)
write(200, { ok: true, runs, viewer: viewerPayload(identity) })
return
}
if (path === `${API_PREFIX}/preview` && method === 'POST') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const body = await readJsonBody(req)
const settings = (await snapshot()).settings
const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone)
@ -611,7 +741,8 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') {
if (!requireBrowserLogin(req, write)) return
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
const state = await snapshot()
const listed = url.searchParams.getAll('id')
write(200, {
@ -626,7 +757,8 @@ 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 === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE') {
if (code === 'LOGIN_REQUIRED') return write(401, { ok: false, error: 'login_required', message: error.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 })
}
if (code === 'PAYLOAD_TOO_LARGE') return write(413, { ok: false, error: 'payload too large' })
@ -649,15 +781,25 @@ export function createHostService(options = {}) {
stopTimer,
handleRequest,
snapshot,
async listJobs() {
return listJobs(await snapshot()).map(jobView)
getUdsAuth,
async listJobs(identity = null) {
const jobs = listJobs(await snapshot())
const filtered = identity ? filterJobsForIdentity(jobs, identity) : jobs
return filtered.map(jobView)
},
async getJob(jobId) {
async getJob(jobId, identity = null) {
const job = getJob(await snapshot(), jobId)
return job ? jobView(job) : null
if (!job) return null
if (identity) assertCanAccessJob(job, identity)
return jobView(job)
},
async listHistory(jobId) {
return listHistory(await snapshot(), jobId).map(runView)
async listHistory(jobId, identity = null) {
const state = await snapshot()
if (jobId && identity) assertCanAccessJob(getJob(state, jobId), identity)
const runs = listHistory(state, jobId)
if (!identity) return runs.map(runView)
const visible = filterJobsForIdentity(listJobs(state), identity)
return filterRunsForJobs(runs, visible).map(runView)
},
workspaceVisibleIds(allIds) {
const hidden = store.snapshot().hiddenSessionIds
@ -748,15 +890,27 @@ function sessionCwdOf(ctx, sessionId) {
}
/**
* Prefer the user's most recently ordered Workspace so a scheduled session
* (and a later native fork) can occupy a real sidebar group. Official
* attachSession requires header.cwd === workspace.path.
* Prefer the job owner's provisioned workspace (uds-auth), then explicit cwd,
* then recent workspace, then shared ops-cron fallback.
* Official attachSession requires header.cwd === workspace.path.
*/
export function resolveSessionPlacement(ctx, job = {}) {
const requested = String(job?.cwd || '').trim()
export function resolveSessionPlacement(ctx, job = {}, deps = {}) {
const workspaces = listWorkspaces(ctx)
const match = (path) => (path && workspaces.find((row) => workspacePathOf(row) === path)) || null
const uds = deps.udsAuth || (typeof deps.getUdsAuth === 'function' ? deps.getUdsAuth() : tryGet(ctx, 'udsAuth'))
const ownerEmpNo = String(job?.ownerEmpNo || '').trim()
const ownerPath = ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.getProvisionedWorkspacePath
? uds.getProvisionedWorkspacePath(ownerEmpNo)
: null
let requested = String(job?.cwd || '').trim()
if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) {
if (!uds.isUserPath(ownerEmpNo, requested) && ownerPath) {
requested = ownerPath
}
}
if (requested) return { cwd: requested, workspace: match(requested) }
if (ownerPath) return { cwd: ownerPath, workspace: match(ownerPath) }
const recent = workspaces[0]
const recentPath = workspacePathOf(recent)
if (recentPath) return { cwd: recentPath, workspace: recent }
@ -1026,7 +1180,8 @@ export function makeLiveSessionPort(ctx) {
throw new Error('ctx.agents.create is unavailable')
}
const sessionId = randomUUID()
const placement = resolveSessionPlacement(ctx, job)
const udsAuth = tryGet(ctx, 'udsAuth')
const placement = resolveSessionPlacement(ctx, job, { udsAuth })
const cwd = placement.cwd || defaultCwd()
await mkdir(cwd, { recursive: true })
const selection = await resolveJobModel(ctx, job)
@ -1062,6 +1217,14 @@ export function makeLiveSessionPort(ctx) {
} catch {
// Forks of unattached runs stay loose; listing hide is separate.
}
try {
const owner = String(job?.ownerEmpNo || '').trim()
if (owner && !owner.startsWith('__')) {
udsAuth?.stampSessionOwner?.(sessionId, owner)
}
} catch {
// Ownership stamp is best-effort.
}
try {
if (typeof agent.session?.append === 'function') {
agent.session.append('session/title', {

9
lib/index.d.ts vendored
View file

@ -15,7 +15,14 @@ export interface Config {
export function apply(ctx: Context, config?: Config): void
export function createHostService(options?: object): object
export function makeLiveSessionPort(ctx: object): object
export function resolveSessionPlacement(ctx: object, job?: object): { cwd: string, workspace: object | null }
export function resolveSessionPlacement(ctx: object, job?: object, deps?: object): { cwd: string, workspace: object | null }
export const UNASSIGNED_OWNER: '__unassigned__'
export function jobVisibleToIdentity(job: object, identity: object): boolean
export function canViewAllJobs(identity: object): boolean
export function assertCanAccessJob(job: object, identity: object): object
export function filterJobsForIdentity(jobs: object[], identity: object): object[]
export function migrateJobOwners(state: object, deps?: object): { state: object, changed: boolean }
export function viewerPayload(identity: object | null): object | null
export function listWorkspaceChoices(ctx: object): Array<{ id: string, title: string, path: string }>
export function listModelChoices(ctx: object): Promise<{ groups: Array<{ provider: string, displayName: string, models: Array<{ id: string, name: string }> }>, current: { provider: string, model: string, reasoningEffort?: string } | null }>
export function resolveJobModel(ctx: object, job?: object): Promise<{ provider: string, model: string, reasoningEffort?: string }>

View file

@ -48,6 +48,15 @@ export {
} from './isolation.js'
export { wrapScheduledPrompt } from './prompt.js'
export { normalizeJobModel } from './store.js'
export {
assertCanAccessJob,
canViewAllJobs,
filterJobsForIdentity,
jobVisibleToIdentity,
migrateJobOwners,
UNASSIGNED_OWNER,
viewerPayload,
} from './ownership.js'
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
export {
deliverRunToIm,
@ -140,10 +149,12 @@ export function apply(ctx, config = {}) {
const getDshIm = () => tryGet(ctx, 'dshIm')
const getAgents = () => tryGet(ctx, 'agents')
const getAgentPresets = () => tryGet(ctx, 'agentPresets')
const getUdsAuth = () => tryGet(ctx, 'udsAuth')
const service = createHostService({
sessionPort: makeLiveSessionPort(ctx),
getDshIm,
getAgents,
getUdsAuth,
logger: ctx.logger,
})

112
lib/ownership.js Normal file
View file

@ -0,0 +1,112 @@
/**
* Job ownership helpers for per-user cron isolation (aligns with uds-auth roles).
*/
export const UNASSIGNED_OWNER = '__unassigned__'
export function normalizeOwnerEmpNo(value) {
const empNo = String(value || '').trim()
return empNo || UNASSIGNED_OWNER
}
export function canViewAllJobs(identity) {
return !!identity?.permissions?.canViewAllSessions
}
export function jobVisibleToIdentity(job, identity) {
if (!identity?.empNo) return false
if (canViewAllJobs(identity)) return true
const owner = normalizeOwnerEmpNo(job?.ownerEmpNo)
if (owner === UNASSIGNED_OWNER) return false
return owner === String(identity.empNo)
}
export function assertCanAccessJob(job, identity) {
if (!job) {
const error = new Error('job not found')
error.code = 'NOT_FOUND'
throw error
}
if (!identity?.empNo) {
const error = new Error('login_required')
error.code = 'LOGIN_REQUIRED'
throw error
}
if (!jobVisibleToIdentity(job, identity)) {
const error = new Error('job not found')
error.code = 'NOT_FOUND'
throw error
}
return job
}
export function filterJobsForIdentity(jobs, identity) {
const list = Array.isArray(jobs) ? jobs : []
if (!identity?.empNo) return []
if (canViewAllJobs(identity)) return [...list]
return list.filter((job) => jobVisibleToIdentity(job, identity))
}
export function filterRunsForJobs(runs, jobs) {
const allowed = new Set((jobs || []).map((job) => job.id))
return (Array.isArray(runs) ? runs : []).filter((run) => allowed.has(run.jobId))
}
/**
* Infer owner for legacy jobs missing ownerEmpNo.
* @param {object} job
* @param {{ getSessionOwner?: (sessionId: string) => string|null }} [deps]
*/
export function inferOwnerEmpNo(job, deps = {}) {
if (job?.ownerEmpNo) return normalizeOwnerEmpNo(job.ownerEmpNo)
const origin = job?.origin
if (origin?.kind === 'im') return UNASSIGNED_OWNER
const sessionId = origin?.kind === 'web' && typeof origin.sessionId === 'string'
? origin.sessionId.trim()
: ''
if (sessionId && typeof deps.getSessionOwner === 'function') {
const owner = deps.getSessionOwner(sessionId)
if (owner) return normalizeOwnerEmpNo(owner)
}
return UNASSIGNED_OWNER
}
/**
* Migrate jobs in-place; returns { state, changed }.
*/
export function migrateJobOwners(state, deps = {}) {
const jobs = Array.isArray(state?.jobs) ? state.jobs : []
let changed = false
const nextJobs = jobs.map((job) => {
if (!job || typeof job !== 'object') return job
if (job.ownerEmpNo) {
const normalized = normalizeOwnerEmpNo(job.ownerEmpNo)
if (normalized === job.ownerEmpNo && job.ownerDisplayName !== undefined) return job
changed = true
return {
...job,
ownerEmpNo: normalized,
ownerDisplayName: job.ownerDisplayName || '',
}
}
changed = true
return {
...job,
ownerEmpNo: inferOwnerEmpNo(job, deps),
ownerDisplayName: job.ownerDisplayName || '',
}
})
if (!changed) return { state, changed: false }
return { state: { ...state, jobs: nextJobs }, changed: true }
}
export function viewerPayload(identity) {
if (!identity?.empNo) return null
return {
empNo: identity.empNo,
role: identity.role || 'user',
displayName: identity.displayName || identity.empNo,
canViewAll: canViewAllJobs(identity),
workspacePath: identity.workspacePath || null,
}
}

View file

@ -10,6 +10,9 @@ import { applyRunIsolation, recordHiddenSession } from './isolation.js'
import { nextFire, validateSchedule } from './scheduler.js'
import { normalizeDelivery, normalizeOrigin } from './delivery.js'
import { normalizeAgentPresetId } from './preset.js'
import { normalizeOwnerEmpNo, UNASSIGNED_OWNER } from './ownership.js'
export { UNASSIGNED_OWNER }
export const STORE_VERSION = 1
@ -97,6 +100,10 @@ export function createJobRecord(input, state, now) {
const delivery = normalizeDelivery(input.delivery)
const origin = normalizeOrigin(input.origin)
const agentPreset = normalizeAgentPresetId(input.agentPreset ?? input.agent_preset)
const ownerEmpNo = normalizeOwnerEmpNo(input.ownerEmpNo)
const ownerDisplayName = typeof input.ownerDisplayName === 'string'
? input.ownerDisplayName.trim().slice(0, 80)
: ''
const job = {
id: String(input.id || newId()),
name,
@ -111,6 +118,8 @@ export function createJobRecord(input, state, now) {
reasoningEffort: model.reasoningEffort,
agentPreset,
delivery,
ownerEmpNo,
ownerDisplayName,
...origin ? { origin } : {},
schedule: schedule.kind === 'cron'
? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone }

View file

@ -12,6 +12,11 @@ import {
resolveCreateOrigin,
} from './delivery.js'
import { resolveCreateAgentPreset } from './preset.js'
import {
assertCanAccessJob,
filterJobsForIdentity,
UNASSIGNED_OWNER,
} from './ownership.js'
const JOB_SCHEMA = {
type: 'object',

View file

@ -74,6 +74,10 @@ async function listen(service) {
}
async function jsonRequest(base, path, options = {}) {
const empNo = options.empNo || 'tester'
const cookie = options.anonymous
? ''
: (options.cookie || `PORTALSSOUser=${encodeURIComponent(empNo)}`)
const response = await fetch(`${base}${path}`, {
...options,
headers: {
@ -81,7 +85,7 @@ async function jsonRequest(base, path, options = {}) {
origin: base,
host: new URL(base).host,
'sec-fetch-site': 'same-origin',
cookie: options.anonymous ? '' : 'PORTALSSOUser=tester',
cookie,
...(options.body ? { 'content-type': 'application/json' } : {}),
...options.headers,
},
@ -90,6 +94,69 @@ async function jsonRequest(base, path, options = {}) {
return { status: response.status, body }
}
function mockUdsAuth(options = {}) {
const owners = new Map()
const users = {
tester: { role: 'user', canViewAll: false, path: '/tmp/user-workspaces/tester', displayName: 'Tester' },
peer: { role: 'user', canViewAll: false, path: '/tmp/user-workspaces/peer', displayName: 'Peer' },
admin1: { role: 'super_admin', canViewAll: true, path: '/tmp/user-workspaces/admin1', displayName: 'Admin' },
administrator: { role: 'fallback_admin', canViewAll: true, path: '/tmp/user-workspaces/administrator', displayName: 'Fallback' },
...(options.users || {}),
}
return {
async resolveRequestIdentity(req) {
const cookie = req?.headers?.cookie || ''
const match = /(?:PORTALSSOUser|UDS_FALLBACK_USER|UDS_FALLBACK_UI)=([^;]+)/.exec(cookie)
if (!match) return null
const empNo = decodeURIComponent(match[1].trim())
const row = users[empNo] || {
role: 'user',
canViewAll: false,
path: `/tmp/user-workspaces/${empNo}`,
displayName: empNo,
}
return {
empNo,
role: row.role,
displayName: row.displayName || empNo,
permissions: { canViewAllSessions: !!row.canViewAll },
workspacePath: row.path,
}
},
canViewAllJobs(identity) {
return !!identity?.permissions?.canViewAllSessions
},
getProvisionedWorkspacePath(empNo) {
return users[empNo]?.path || `/tmp/user-workspaces/${empNo}`
},
isUserPath(empNo, candidatePath) {
if (!candidatePath) return false
if (options.strictPath) {
const root = this.getProvisionedWorkspacePath(empNo)
const cand = String(candidatePath)
return cand === root || cand.startsWith(`${root}/`) || cand.startsWith(`${root}\\`)
}
return true
},
stampSessionOwner(sessionId, empNo) {
if (sessionId && empNo) owners.set(String(sessionId), String(empNo))
},
getSessionOwner(sessionId) {
return owners.get(String(sessionId)) || null
},
}
}
function createTestHost(options = {}) {
const { udsAuthOptions, udsAuth, ...rest } = options
const auth = udsAuth || mockUdsAuth(udsAuthOptions)
return createHostService({
...rest,
getUdsAuth: () => auth,
})
}
test('host service: create, list, run-now, history, and workspace isolation', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
@ -97,7 +164,7 @@ test('host service: create, list, run-now, history, and workspace isolation', as
const archived = []
let clock = Date.parse('2026-08-24T01:00:00.000Z')
let sessionSeq = 0
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => clock,
sessionPort: {
@ -169,7 +236,7 @@ test('overlap skip writes a skipped history row instead of a second session', as
t.after(() => rm(dir, { recursive: true, force: true }))
let clock = Date.parse('2026-08-24T01:00:00.000Z')
let inflight = 0
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => clock,
sessionPort: {
@ -197,7 +264,7 @@ test('overlap skip writes a skipped history row instead of a second session', as
test('http api: anonymous list/run-now is rejected', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort: {
@ -209,7 +276,7 @@ test('http api: anonymous list/run-now is rejected', async (t) => {
name: 'secret job',
prompt: 'do not leak',
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' },
})
}, { empNo: 'tester', displayName: 'Tester', permissions: { canViewAllSessions: false } })
const http = await listen(service)
t.after(() => http.close())
const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { anonymous: true })
@ -217,6 +284,7 @@ test('http api: anonymous list/run-now is rejected', async (t) => {
assert.equal(listed.body.error, 'login_required')
const jobs = await jsonRequest(http.url, '/dsh-ops-cron/jobs')
assert.equal(jobs.status, 200)
assert.equal(jobs.body.jobs.length, 1)
const jobId = jobs.body.jobs[0].id
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, {
method: 'POST',
@ -229,7 +297,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort: {
@ -274,7 +342,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
test('createJob ignores client-supplied id to prevent overwrite', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
})
@ -299,7 +367,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const revealed = []
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort: {
@ -391,7 +459,7 @@ test('POST /conceal archives every run session id', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort: {
@ -424,7 +492,7 @@ test('one-shot at job fires once then later ticks wait instead of retriggering',
const at = Date.parse('2026-08-23T14:57:00.000Z')
let clock = at - 60_000
let sessions = 0
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => clock,
sessionPort: {
@ -568,7 +636,7 @@ test('listModelChoices maps llm providers and the current default', async () =>
test('GET /models lists session-port catalog', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
sessionPort: {
async listModels() {
@ -607,7 +675,7 @@ test('listWorkspaceChoices maps registry entries to title and path', () => {
test('GET /workspaces lists session-port choices', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
sessionPort: {
async listWorkspaces() {
@ -626,7 +694,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const adopted = []
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
sessionPort: {
async adoptSession(sessionId) {
@ -660,7 +728,7 @@ test('after one live-shaped dispatch, the next run-now still fires', async (t) =
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort,
@ -695,7 +763,7 @@ test('after a live-shaped due tick settles, the next due occurrence still fires'
const archived = []
let clock = Date.parse('2026-08-24T01:00:00.000Z')
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
const service = createHostService({
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => clock,
sessionPort,
@ -720,3 +788,97 @@ test('after a live-shaped due tick settles, the next due occurrence still fires'
assert.notEqual(secondTick[0].run.sessionId, firstTick[0].run.sessionId)
assert.notEqual(secondTick[0].run.status, 'skipped')
})
test('host HTTP: per-user job isolation and super sees all', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-acl-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createTestHost({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort: {
async createAndPrompt({ job }) {
return { sessionId: `sess-${job.id}`, status: 'succeeded', summary: 'ok' }
},
async archiveSession() { return true },
},
})
const http = await listen(service)
t.after(() => http.close())
const noAuth = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { anonymous: true })
assert.equal(noAuth.status, 401)
const createdA = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
empNo: 'tester',
body: JSON.stringify({
name: 'tester-job',
prompt: 'do a',
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
}),
})
assert.equal(createdA.status, 200)
assert.equal(createdA.body.job.ownerEmpNo, 'tester')
assert.ok(createdA.body.job.ownerDisplayName)
const createdB = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
empNo: 'peer',
body: JSON.stringify({
name: 'peer-job',
prompt: 'do b',
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
}),
})
assert.equal(createdB.status, 200)
assert.equal(createdB.body.job.ownerEmpNo, 'peer')
const listTester = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'tester' })
assert.equal(listTester.status, 200)
assert.equal(listTester.body.jobs.length, 1)
assert.equal(listTester.body.jobs[0].name, 'tester-job')
assert.equal(listTester.body.viewer.canViewAll, false)
const listPeer = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'peer' })
assert.equal(listPeer.body.jobs.length, 1)
assert.equal(listPeer.body.jobs[0].name, 'peer-job')
const forbidden = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${createdB.body.job.id}`, { empNo: 'tester' })
assert.equal(forbidden.status, 404)
const listAdmin = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'admin1' })
assert.equal(listAdmin.status, 200)
assert.equal(listAdmin.body.viewer.canViewAll, true)
assert.equal(listAdmin.body.jobs.length, 2)
const reassigned = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${createdB.body.job.id}`, {
method: 'PATCH',
empNo: 'admin1',
body: JSON.stringify({ ownerEmpNo: 'tester', ownerDisplayName: 'Tester' }),
})
assert.equal(reassigned.status, 200)
assert.equal(reassigned.body.job.ownerEmpNo, 'tester')
const listTester2 = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'tester' })
assert.equal(listTester2.body.jobs.length, 2)
})
test('migrateJobOwners assigns unassigned for legacy jobs', async () => {
const { migrateJobOwners, UNASSIGNED_OWNER } = await import('../lib/ownership.js')
const state = {
jobs: [
{ id: '1', name: 'a', origin: { kind: 'im', peer: { botId: 'b' } } },
{ id: '2', name: 'b', origin: { kind: 'web', sessionId: 's1' } },
{ id: '3', name: 'c', ownerEmpNo: 'u1' },
],
}
const { state: next, changed } = migrateJobOwners(state, {
getSessionOwner: (id) => (id === 's1' ? 'from-session' : null),
})
assert.equal(changed, true)
assert.equal(next.jobs[0].ownerEmpNo, UNASSIGNED_OWNER)
assert.equal(next.jobs[1].ownerEmpNo, 'from-session')
assert.equal(next.jobs[2].ownerEmpNo, 'u1')
})

74
test/ownership.test.js Normal file
View file

@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
assertCanAccessJob,
canViewAllJobs,
filterJobsForIdentity,
filterRunsForJobs,
inferOwnerEmpNo,
jobVisibleToIdentity,
migrateJobOwners,
UNASSIGNED_OWNER,
viewerPayload,
} from '../lib/ownership.js'
test('jobVisibleToIdentity respects canViewAll and owner', () => {
const user = { empNo: 'u1', permissions: { canViewAllSessions: false } }
const admin = { empNo: 'a1', permissions: { canViewAllSessions: true } }
assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u1' }, user), true)
assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u2' }, user), false)
assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, user), false)
assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, admin), true)
assert.equal(canViewAllJobs(admin), true)
})
test('assertCanAccessJob throws NOT_FOUND for foreigners', () => {
const user = { empNo: 'u1', permissions: { canViewAllSessions: false } }
assert.throws(
() => assertCanAccessJob({ id: 'j', ownerEmpNo: 'u2' }, user),
(err) => err && err.code === 'NOT_FOUND',
)
assert.equal(assertCanAccessJob({ id: 'j', ownerEmpNo: 'u1' }, user).id, 'j')
})
test('filterJobsForIdentity and filterRunsForJobs', () => {
const user = { empNo: 'u1', permissions: { canViewAllSessions: false } }
const jobs = [
{ id: '1', ownerEmpNo: 'u1' },
{ id: '2', ownerEmpNo: 'u2' },
]
const visible = filterJobsForIdentity(jobs, user)
assert.deepEqual(visible.map((j) => j.id), ['1'])
const runs = filterRunsForJobs([
{ id: 'r1', jobId: '1' },
{ id: 'r2', jobId: '2' },
], visible)
assert.deepEqual(runs.map((r) => r.id), ['r1'])
})
test('inferOwnerEmpNo and migrateJobOwners', () => {
assert.equal(inferOwnerEmpNo({ origin: { kind: 'im' } }), UNASSIGNED_OWNER)
assert.equal(
inferOwnerEmpNo({ origin: { kind: 'web', sessionId: 's' } }, { getSessionOwner: () => 'own' }),
'own',
)
const { changed, state } = migrateJobOwners({
jobs: [{ id: 'x', name: 'n' }],
})
assert.equal(changed, true)
assert.equal(state.jobs[0].ownerEmpNo, UNASSIGNED_OWNER)
})
test('viewerPayload', () => {
assert.equal(viewerPayload(null), null)
const v = viewerPayload({
empNo: 'u1',
role: 'user',
displayName: 'U',
permissions: { canViewAllSessions: false },
workspacePath: '/w/u1',
})
assert.equal(v.empNo, 'u1')
assert.equal(v.canViewAll, false)
assert.equal(v.workspacePath, '/w/u1')
})