mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 00:43:22 +08:00
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:
parent
0ffc48b43a
commit
7d27179e51
12 changed files with 785 additions and 141 deletions
11
README.md
11
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
15
docs/PRD.md
15
docs/PRD.md
|
|
@ -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 不覆盖。
|
||||
|
|
|
|||
219
lib/client.js
219
lib/client.js
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
267
lib/host.js
267
lib/host.js
|
|
@ -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
9
lib/index.d.ts
vendored
|
|
@ -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 }>
|
||||
|
|
|
|||
11
lib/index.js
11
lib/index.js
|
|
@ -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
112
lib/ownership.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
74
test/ownership.test.js
Normal 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')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue