Fix cron cwd for super_admin and harden IM channel job isolation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 10:00:59 +08:00
parent b35bfde557
commit b1bb0067a0
16 changed files with 714 additions and 49 deletions

View file

@ -378,9 +378,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停',
scheduleTz: '时区', cwd: '工作目录', timeout: '超时(分钟)',
cwdRecent: '最近使用的工作区', cwdCustom: '自定义路径…',
cwdRecent: '归属工作区(默认)', cwdCustom: '自定义路径…',
cwdPlaceholder: '/absolute/path',
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。',
cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用任务归属者的工作区(不是最近打开的项目)。',
model: '模型',
modelDefault: '每次运行用当时的新会话默认',
modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。',
@ -399,7 +399,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
imTargetManual: '手动填写…',
imCatalogLoading: '正在加载投递目标…',
imCatalogUnavailable: '无法加载投递目标(需 dsh-im-ops ≥ops.24,且已配置投递目标)',
deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。',
deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标(仅超管)。普通用户请在 IM 聊天里用 cron_create 建渠道任务。目标在 IM 机器人 → 投递设置里创建。',
mirrorToSession: '镜像回原会话',
mirrorToSessionHint: '开启后把每次运行摘要写入创建时的 WhatsApp/Web 会话(不新开模型轮次)。默认关闭。',
loginRequired: '登录后才能使用定时任务',
@ -424,9 +424,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
search: 'Search', searchPlaceholder: 'Search jobs', searchClear: 'Clear search', searchEmpty: 'No matching jobs.',
paused: 'Paused',
scheduleTz: 'Time zone', cwd: 'Working directory', timeout: 'Timeout (minutes)',
cwdRecent: 'Most recent workspace', cwdCustom: 'Custom path…',
cwdRecent: 'Owner workspace (default)', cwdCustom: 'Custom path…',
cwdPlaceholder: '/absolute/path',
cwdHint: 'Runs start a session in this workspace folder. Leave empty to use the most recent workspace.',
cwdHint: 'Runs start a session in this workspace folder. Leave empty to use the job owner provisioned workspace (not the most recently opened project).',
model: 'Model',
modelDefault: 'Use the New Session default at fire time',
modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.',
@ -445,7 +445,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
imTargetManual: 'Enter manually…',
imCatalogLoading: 'Loading delivery targets…',
imCatalogUnavailable: 'Cannot load targets (need dsh-im-ops ≥ops.24 with saved targets)',
deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.',
deliveryHint: 'Pick a saved IM delivery target (super_admin only). Regular users should create channel jobs from the IM chat via cron_create. Create targets under IM bot → Delivery settings.',
mirrorToSession: 'Mirror into origin session',
mirrorToSessionHint: 'When enabled, append each run summary into the creating WhatsApp/Web session (no new model turn). Off by default.',
loginRequired: 'Sign in to use scheduled tasks',
@ -978,6 +978,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return String(row?.cwd || '').trim()
}
/** Prefer provisioned viewer workspace over the currently open project. */
function preferredNewJobCwd(viewer, sessionCwd) {
const provisioned = String(viewer?.workspacePath || '').trim()
if (viewer?.mode === 'multi' && provisioned) return provisioned
if (provisioned) return provisioned
return String(sessionCwd || '').trim()
}
function cwdSelectValue(cwd, workspaces) {
const value = String(cwd || '').trim()
if (!value) return ''
@ -1185,10 +1193,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
onChange: (e) => setForm({ ...form, deliveryKind: e.target.value }),
},
h('option', { value: 'dsh' }, t('deliveryDsh')),
h('option', { value: 'im' }, t('deliveryIm')),
...(viewer?.mode !== 'multi' || viewer?.canViewAll
? [h('option', { value: 'im' }, t('deliveryIm'))]
: []),
),
),
form.deliveryKind === 'im'
form.deliveryKind === 'im' && (viewer?.mode !== 'multi' || viewer?.canViewAll)
? h(ImDeliveryFields, { t, form, setForm, imCatalog })
: null,
h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')),
@ -1384,7 +1394,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
setForm((prev) => {
let next = prev
if (!next.cwd) {
const cwd = currentWorkspacePath(faces)
const cwd = preferredNewJobCwd(viewer, currentWorkspacePath(faces))
if (cwd) next = { ...next, cwd }
}
if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) {
@ -1395,7 +1405,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
}
return next
})
}, [cronMode, selection.type, catalog.current, presets.current])
}, [cronMode, selection.type, catalog.current, presets.current, viewer])
function expandJobPath(jobId) {
const job = jobs.find((row) => row.id === jobId)
@ -1414,7 +1424,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
skipAutoSelect.current = true
setSelection({ type: 'new' })
const next = emptyForm(undefined, catalog.current)
next.cwd = currentWorkspacePath(faces)
next.cwd = preferredNewJobCwd(viewer, currentWorkspacePath(faces))
setForm(next)
}

View file

@ -4,6 +4,7 @@
*/
import { DEFAULT_LOCALE, t } from './i18n.js'
import { canViewAllJobs, isMultiUserIdentity } from './ownership.js'
export function normalizeDelivery(input = {}) {
const src = input && typeof input === 'object' ? input : {}
@ -28,6 +29,25 @@ export function normalizeDelivery(input = {}) {
return { kind: 'dsh' }
}
/**
* Web/sidebar callers who are not super_admin must not aim IM delivery at
* arbitrary bot/target ids (confused deputy). Channel jobs bind delivery via peer.
* @param {object|null|undefined} delivery
* @param {object|null|undefined} identity
*/
export function assertDeliveryAllowedForIdentity(delivery, identity) {
if (!delivery || delivery.kind !== 'im') return delivery
if (!identity?.empNo) return delivery
// Standalone local identity may still configure IM for single-user hosts.
if (!isMultiUserIdentity(identity)) return delivery
if (canViewAllJobs(identity)) return delivery
const error = new Error(
'only super_admin can set WhatsApp/IM delivery from the web sidebar; create channel jobs from the IM chat',
)
error.code = 'IM_DELIVERY_FORBIDDEN'
throw error
}
/**
* Keep creator @mention when the UI re-saves the same IM target without mention fields.
* @param {object|null|undefined} previous

View file

@ -108,9 +108,18 @@ export function claimOccurrence(state, jobId, now, trigger, policies) {
if (decision.action === 'skip') {
run.status = 'skipped'
run.reason = decision.reason
run.summary = decision.reason === 'overlap'
? 'Skipped because a run is already queued or running'
: 'Skipped missed occurrence after host downtime (no backlog)'
const oneshot = job.schedule?.kind === 'at'
if (decision.reason === 'overlap') {
run.summary = 'Skipped because a run is already queued or running'
} else if (decision.reason === 'misfire' && oneshot) {
run.summary = 'Skipped: one-shot time missed (host downtime or delayed tick; no backlog). nextRunAt cleared.'
run.error = 'misfire:one-shot'
} else if (decision.reason === 'misfire') {
run.summary = 'Skipped missed occurrence after host downtime (no backlog)'
run.error = 'misfire'
} else {
run.summary = `Skipped (${decision.reason || 'policy'})`
}
const alreadySkipped = runs.some((row) => (
row
&& row.status === 'skipped'

View file

@ -8,7 +8,7 @@ import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
import { deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeOrigin } from './delivery.js'
import { assertDeliveryAllowedForIdentity, deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeDelivery, normalizeOrigin } from './delivery.js'
import { apiError, resolveLocale } from './i18n.js'
import { workspaceVisibleIds } from './isolation.js'
import { decideDispatch, nextFire, validateSchedule } from './scheduler.js'
@ -99,6 +99,10 @@ function inferIdentityFromJobInput(input, getUdsAuth) {
if (sessionId && typeof uds.getSessionOwner === 'function') {
const empNo = uds.getSessionOwner(sessionId)
if (empNo) {
if (typeof uds.resolveIdentityForEmpNo === 'function') {
const resolved = uds.resolveIdentityForEmpNo(empNo)
if (resolved?.empNo) return resolved
}
return {
empNo: String(empNo),
displayName: String(empNo),
@ -108,6 +112,10 @@ function inferIdentityFromJobInput(input, getUdsAuth) {
}
const fromCwd = empNoFromUserWorkspacePath(input?.cwd)
if (fromCwd) {
if (typeof uds.resolveIdentityForEmpNo === 'function') {
const resolved = uds.resolveIdentityForEmpNo(fromCwd)
if (resolved?.empNo) return resolved
}
return {
empNo: fromCwd,
displayName: fromCwd,
@ -312,22 +320,52 @@ export function createHostService(options = {}) {
return { job: jobView(job), run: runView(run), decision: claimedDecision }
}
async function createJob(input, identity = null) {
async function createJob(input, identity = null, opts = {}) {
const t = now()
// Ignore client-supplied ids on create — otherwise POST/tools can overwrite.
const { id: _ignoredId, ownerEmpNo: _ignoreOwner, ...safeInput } = input && typeof input === 'object' ? input : {}
let ownerIdentity = isMultiUserIdentity(identity)
? identity
: inferIdentityFromJobInput(safeInput, getUdsAuth)
if (isMultiUserIdentity(ownerIdentity)) {
safeInput.ownerEmpNo = ownerIdentity.empNo
safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo })
} else if (safeInput.ownerEmpNo) {
safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo)
} else {
// Standalone / no inferred owner: leave unassigned (visible to everyone in local mode).
const fromImPeer = opts.fromImPeer === true
let origin = normalizeOrigin(safeInput.origin)
// HTTP/web must not forge IM peer origin to bypass empNo ownership.
if (!fromImPeer && origin?.kind === 'im') {
origin = origin.sessionId
? normalizeOrigin({ kind: 'web', sessionId: origin.sessionId })
: null
if (origin) safeInput.origin = origin
else delete safeInput.origin
} else if (origin) {
safeInput.origin = origin
}
if (fromImPeer) {
const cwd = String(safeInput.cwd || '').trim()
if (!cwd) {
const error = new Error('IM scheduled jobs require a non-empty session working directory')
error.code = 'INVALID_CWD'
throw error
}
safeInput.cwd = cwd
safeInput.ownerEmpNo = UNASSIGNED_OWNER
safeInput.ownerDisplayName = ''
// Keep cwd as the bot workspace path; do not sanitize via empNo.
} else {
const ownerIdentity = isMultiUserIdentity(identity)
? identity
: inferIdentityFromJobInput(safeInput, getUdsAuth)
if (isMultiUserIdentity(ownerIdentity)) {
const delivery = normalizeDelivery(safeInput.delivery || { kind: 'dsh' })
assertDeliveryAllowedForIdentity(delivery, ownerIdentity)
safeInput.delivery = delivery
safeInput.ownerEmpNo = ownerIdentity.empNo
safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo })
} else if (safeInput.ownerEmpNo) {
safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo)
} else {
// Standalone / no inferred owner: leave unassigned (visible to everyone in local mode).
safeInput.ownerEmpNo = UNASSIGNED_OWNER
}
}
let created
await withState((current) => {
@ -374,6 +412,10 @@ export function createHostService(options = {}) {
ownerDisplayName: job.ownerDisplayName || '',
}
if (patch._identity) {
if (patch.delivery !== undefined) {
nextInput.delivery = mergeDeliveryMention(job.delivery, patch.delivery)
assertDeliveryAllowedForIdentity(nextInput.delivery, patch._identity)
}
nextInput.cwd = sanitizeJobCwd(
nextInput.cwd,
patch._identity,
@ -636,6 +678,15 @@ export function createHostService(options = {}) {
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
const identity = await requireIdentity(req, write, getUdsAuth)
if (!identity) return
if (!canViewAllJobs(identity)) {
write(200, {
ok: true,
available: true,
options: [],
hint: 'IM delivery from the web sidebar is super_admin-only; create channel jobs from the WhatsApp/IM chat',
})
return
}
const dshIm = getDshIm()
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
write(200, {
@ -817,7 +868,7 @@ export function createHostService(options = {}) {
const locale = resolveLocale(req)
const code = error && error.code
if (code === 'NOT_FOUND') return write(404, { ...apiError('not_found', locale), error: error.message || 'not_found' })
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD') {
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD' || code === 'INVALID_DELIVERY' || code === 'IM_DELIVERY_FORBIDDEN' || code === 'IM_TARGET_FORBIDDEN' || code === 'IM_JOB_MISSING_CWD') {
return write(400, { ok: false, error: error.message, code, message: error.message })
}
if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale))
@ -952,8 +1003,29 @@ function sessionCwdOf(ctx, sessionId) {
}
/**
* Prefer the job owner's provisioned workspace (uds-auth), then explicit cwd,
* then recent workspace, then shared ops-cron fallback.
* True when the job owner may keep an explicit cwd outside their provisioned tree
* (super_admin / fallback_admin via canViewAllSessions or canCreateWorkspace).
*/
export function ownerAllowsForeignCwd(ownerEmpNo, uds) {
const empNo = String(ownerEmpNo || '').trim()
if (!empNo || empNo.startsWith('__') || !uds) return false
if (typeof uds.resolveIdentityForEmpNo === 'function') {
const identity = uds.resolveIdentityForEmpNo(empNo)
return !!(
identity?.permissions?.canViewAllSessions
|| identity?.permissions?.canCreateWorkspace
)
}
return false
}
/**
* Placement order:
* 1. Explicit job.cwd (kept for super_admin owners; clamped to owner tree for users)
* 2. Owner provisioned path (multi-user)
* 3. Recent registry workspace — only standalone / non-IM unassigned
* 4. Shared ops-cron fallback
* IM-origin jobs never fall back to workspaces[0]; missing cwd → missingCwd.
* Official attachSession requires header.cwd === workspace.path.
*/
export function resolveSessionPlacement(ctx, job = {}, deps = {}) {
@ -964,20 +1036,27 @@ export function resolveSessionPlacement(ctx, job = {}, deps = {}) {
const ownerPath = ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.getProvisionedWorkspacePath
? uds.getProvisionedWorkspacePath(ownerEmpNo)
: null
const imOrigin = normalizeOrigin(job?.origin)?.kind === 'im'
let requested = String(job?.cwd || '').trim()
if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) {
if (!uds.isUserPath(ownerEmpNo, requested) && ownerPath) {
const allowForeign = ownerAllowsForeignCwd(ownerEmpNo, uds)
if (!uds.isUserPath(ownerEmpNo, requested) && !allowForeign && ownerPath) {
requested = ownerPath
}
}
if (requested) return { cwd: requested, workspace: match(requested) }
if (ownerPath) return { cwd: ownerPath, workspace: match(ownerPath) }
if (requested) return { cwd: requested, workspace: match(requested), missingCwd: false }
if (imOrigin) {
return { cwd: '', workspace: null, missingCwd: true }
}
if (ownerPath) return { cwd: ownerPath, workspace: match(ownerPath), missingCwd: false }
// Do not use workspaces[0] for owned multi-user jobs (ownerPath already handled).
// Standalone / unassigned (non-IM) may still use the most recent registry workspace.
const recent = workspaces[0]
const recentPath = workspacePathOf(recent)
if (recentPath) return { cwd: recentPath, workspace: recent }
if (recentPath) return { cwd: recentPath, workspace: recent, missingCwd: false }
const isolated = defaultCwd()
return { cwd: isolated, workspace: match(isolated) }
return { cwd: isolated, workspace: match(isolated), missingCwd: false }
}
export async function attachLiveSessionToWorkspace(workspace, sessionId) {
@ -1244,6 +1323,11 @@ export function makeLiveSessionPort(ctx) {
const sessionId = randomUUID()
const udsAuth = tryGet(ctx, 'udsAuth')
const placement = resolveSessionPlacement(ctx, job, { udsAuth })
if (placement.missingCwd) {
const error = new Error('IM scheduled job has no cwd; recreate it from the channel chat')
error.code = 'IM_JOB_MISSING_CWD'
throw error
}
const cwd = placement.cwd || defaultCwd()
await mkdir(cwd, { recursive: true })
const selection = await resolveJobModel(ctx, job)

3
lib/index.d.ts vendored
View file

@ -16,6 +16,7 @@ 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, deps?: object): { cwd: string, workspace: object | null }
export function ownerAllowsForeignCwd(ownerEmpNo: string, uds?: object | null): boolean
export const UNASSIGNED_OWNER: '__unassigned__'
export const LOCAL_EMP_NO: '__local__'
export function isMultiUserIdentity(identity: object | null | undefined): boolean
@ -27,6 +28,7 @@ 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 preferredNewJobCwd(input?: { viewer?: object | null, sessionCwd?: string }): string
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 }>
@ -35,6 +37,7 @@ export function normalizeJobModel(input?: object): { provider: string, model: st
export function callerWorkingDirectory(exec?: object): string
export function resolveCreateCwd(args?: object, exec?: object): string
export function resolveCreateModel(args?: object, exec?: object): { provider: string, model: string, reasoningEffort: string }
export function resolveToolIdentity(exec?: object, service?: object): object | null
export function callerModelSelection(exec?: object): { provider: string, model: string, reasoningEffort: string }
export function scheduleFromArgs(args?: object, nowMs?: number): { kind: string, at?: string, expr?: string, timezone: string }
export function adoptSessionIntoWorkspace(ctx: object, sessionId: string): Promise<{ ok: boolean, attached: boolean, sessionId?: string, cwd?: string | null, workspaceId?: string | null }>

View file

@ -31,12 +31,13 @@ export {
listModelChoices,
listWorkspaceChoices,
makeLiveSessionPort,
ownerAllowsForeignCwd,
resolveDefaultModel,
resolveJobModel,
resolveSessionPlacement,
waitForAgentTurn,
} from './host.js'
export { callerWorkingDirectory, callerModelSelection, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from './tools.js'
export { callerWorkingDirectory, callerModelSelection, registerCronTools, resolveCreateCwd, resolveCreateModel, resolveToolIdentity, scheduleFromArgs } from './tools.js'
export { decideDispatch, nextFire, parseCron, tickJobs, validateSchedule } from './scheduler.js'
export {
applyRunIsolation,
@ -58,11 +59,13 @@ export {
LOCAL_EMP_NO,
localIdentity,
migrateJobOwners,
preferredNewJobCwd,
UNASSIGNED_OWNER,
viewerPayload,
} from './ownership.js'
export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js'
export {
assertDeliveryAllowedForIdentity,
deliverRunToIm,
formatRunResultBody,
jobVisibleToPeer,

View file

@ -139,6 +139,18 @@ export function viewerPayload(identity) {
}
}
/**
* Default cwd when creating a job in the sidebar.
* Prefer the viewer's provisioned workspace over whatever project the session
* happens to have open (admins often sit in an unrelated clone).
*/
export function preferredNewJobCwd({ viewer = null, sessionCwd = '' } = {}) {
const provisioned = String(viewer?.workspacePath || '').trim()
if (viewer?.mode === 'multi' && provisioned) return provisioned
if (provisioned) return provisioned
return String(sessionCwd || '').trim()
}
/**
* Claim unassigned jobs that clearly belong to the viewer (origin session / cwd).
* No-op in standalone / canViewAll. Returns { state, changed }.

View file

@ -133,10 +133,16 @@ export function createJobRecord(input, state, now) {
lastStatus: null,
nextRunAt: nextFire(schedule, now, schedule.timezone),
}
if (schedule.kind === 'at' && (job.nextRunAt == null || job.nextRunAt < now - 60_000)) {
const error = new Error(`that one-shot time is already in the past (now is ${new Date(now).toISOString()})`)
error.code = 'INVALID_AT'
throw error
if (schedule.kind === 'at') {
const atMs = Date.parse(job.schedule.at)
// Slightly past (within 60s): fire on next tick instead of rejecting or freezing.
if (Number.isFinite(atMs) && atMs <= now && atMs >= now - 60_000) {
job.nextRunAt = now
} else if (job.nextRunAt == null || job.nextRunAt < now - 60_000) {
const error = new Error(`that one-shot time is already in the past (now is ${new Date(now).toISOString()})`)
error.code = 'INVALID_AT'
throw error
}
}
return job
}

View file

@ -5,6 +5,7 @@
import { formatInZone, resolveTodayAt } from './scheduler.js'
import {
assertDeliveryAllowedForIdentity,
deliveryLine,
jobVisibleToPeer,
resolveCallerPeer,
@ -125,7 +126,14 @@ export function resolveCreateCwd(args, exec, { peer = null } = {}) {
const sessionCwd = callerWorkingDirectory(exec)
const passed = typeof args?.cwd === 'string' ? args.cwd.trim() : ''
// IM peers cannot point scheduled Agents at arbitrary host paths.
if (peer?.botId) return sessionCwd
if (peer?.botId) {
if (!sessionCwd) {
const error = new Error('IM scheduled jobs require a non-empty session working directory')
error.code = 'INVALID_CWD'
throw error
}
return sessionCwd
}
if (passed) return passed
return sessionCwd
}
@ -148,6 +156,7 @@ function empNoFromUserWorkspacePath(cwd) {
/**
* Resolve web caller identity for ownership. IM peers keep conversation scoping.
* Prefer uds-auth role lookup so super_admin keeps canViewAllSessions (cwd sanitize).
*/
export function resolveToolIdentity(exec, service) {
const uds = typeof service?.getUdsAuth === 'function' ? service.getUdsAuth() : null
@ -160,11 +169,28 @@ export function resolveToolIdentity(exec, service) {
empNo = empNoFromUserWorkspacePath(callerWorkingDirectory(exec))
}
if (!empNo) return null
const id = String(empNo)
if (typeof uds?.resolveIdentityForEmpNo === 'function') {
const resolved = uds.resolveIdentityForEmpNo(id)
if (resolved?.empNo) {
return {
empNo: String(resolved.empNo),
displayName: String(resolved.displayName || resolved.empNo),
role: resolved.role || 'user',
permissions: {
canViewAllSessions: !!resolved.permissions?.canViewAllSessions,
canCreateWorkspace: !!resolved.permissions?.canCreateWorkspace,
},
workspacePath: resolved.workspacePath || uds.getProvisionedWorkspacePath?.(id) || null,
}
}
}
return {
empNo: String(empNo),
displayName: String(empNo),
empNo: id,
displayName: id,
role: 'user',
permissions: { canViewAllSessions: false },
workspacePath: uds?.getProvisionedWorkspacePath?.(id) || null,
}
}
@ -295,8 +321,12 @@ export function cronToolDefinitions(service, deps = {}) {
const dshIm = getDshIm()
try {
const peer = await resolveCallerPeer(exec, dshIm)
const identity = resolveToolIdentity(exec, service)
// Channel chats scope by peer; do not stamp empNo (avoids sanitize clobbering bot cwd).
const identity = peer?.botId ? null : resolveToolIdentity(exec, service)
const delivery = await resolveCreateDelivery(args, exec, { dshIm })
if (identity && !peer?.botId) {
assertDeliveryAllowedForIdentity(delivery, identity)
}
const origin = await resolveCreateOrigin(args, exec, { dshIm })
const agentPreset = await resolveCreateAgentPreset(args, exec, {
dshIm,
@ -314,7 +344,7 @@ export function cronToolDefinitions(service, deps = {}) {
mirrorToSession: args.mirror_to_session === true,
...origin ? { origin } : {},
agentPreset,
}, identity)
}, identity, { fromImPeer: !!peer?.botId })
return { job }
} catch (error) {
const tz = args.timezone || args.time_zone || 'Asia/Shanghai'

View file

@ -1,7 +1,7 @@
{
"name": "dsh-ops-cron",
"description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.",
"version": "0.1.9",
"version": "0.1.11",
"private": false,
"type": "module",
"repository": {

View file

@ -11,6 +11,7 @@ import {
deliverRunToIm,
mirrorRunToSession,
jobVisibleToPeer,
assertDeliveryAllowedForIdentity,
} from '../lib/delivery.js'
test('normalizeDelivery defaults to dsh', () => {
@ -439,3 +440,17 @@ test('formatRunResultBody matches IM delivery header', () => {
const en = formatRunResultBody({ name: 'Daily' }, 'ok', 'en')
assert.match(en, /^\[Scheduled tasks · Daily\]\n/)
})
test('assertDeliveryAllowedForIdentity blocks non-admin web IM delivery', () => {
const im = { kind: 'im', botId: 'b', targetId: 't' }
assert.equal(
assertDeliveryAllowedForIdentity(im, { empNo: 'a1', permissions: { canViewAllSessions: true } }),
im,
)
assert.doesNotThrow(() => assertDeliveryAllowedForIdentity(im, { empNo: '__local__', permissions: { canViewAllSessions: true } }))
assert.throws(
() => assertDeliveryAllowedForIdentity(im, { empNo: 'u1', permissions: { canViewAllSessions: false } }),
(error) => error.code === 'IM_DELIVERY_FORBIDDEN',
)
assert.doesNotThrow(() => assertDeliveryAllowedForIdentity({ kind: 'dsh' }, { empNo: 'u1', permissions: {} }))
})

View file

@ -100,6 +100,7 @@ function mockUdsAuth(options = {}) {
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' },
'10329667': { role: 'super_admin', canViewAll: true, path: '/tmp/deepseek-harness/10329667', displayName: 'Super' },
administrator: { role: 'fallback_admin', canViewAll: true, path: '/tmp/user-workspaces/administrator', displayName: 'Fallback' },
...(options.users || {}),
}
@ -119,7 +120,30 @@ function mockUdsAuth(options = {}) {
empNo,
role: row.role,
displayName: row.displayName || empNo,
permissions: { canViewAllSessions: !!row.canViewAll },
permissions: {
canViewAllSessions: !!row.canViewAll,
canCreateWorkspace: !!row.canViewAll,
},
workspacePath: row.path,
}
},
resolveIdentityForEmpNo(empNo) {
const id = String(empNo || '').trim()
if (!id || id.startsWith('__')) return null
const row = users[id] || {
role: 'user',
canViewAll: false,
path: `/tmp/user-workspaces/${id}`,
displayName: id,
}
return {
empNo: id,
role: row.role,
displayName: row.displayName || id,
permissions: {
canViewAllSessions: !!row.canViewAll,
canCreateWorkspace: !!row.canViewAll,
},
workspacePath: row.path,
}
},
@ -133,8 +157,9 @@ function mockUdsAuth(options = {}) {
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}\\`)
const cand = String(candidatePath).replace(/\\/g, '/')
const normRoot = String(root).replace(/\\/g, '/')
return cand === normRoot || cand.startsWith(`${normRoot}/`)
}
return true
},
@ -562,6 +587,243 @@ test('resolveSessionPlacement uses recent workspace path when job cwd is empty',
assert.equal(explicit.workspace, null)
})
test('resolveSessionPlacement keeps foreign cwd for super_admin owner under strict path ACL', () => {
const recent = { id: 'ws-recent', path: '/tmp/ws-app', async attachSession() {} }
const ctx = {
get(name) {
if (name === 'workspaceRegistry') return { list: () => [recent] }
return undefined
},
}
const uds = mockUdsAuth({ strictPath: true })
const kept = resolveSessionPlacement(ctx, {
cwd: 'D:/code/gpt',
ownerEmpNo: '10329667',
}, { udsAuth: uds })
assert.equal(kept.cwd, 'D:/code/gpt')
const clamped = resolveSessionPlacement(ctx, {
cwd: 'D:/code/gpt',
ownerEmpNo: 'tester',
}, { udsAuth: uds })
assert.equal(clamped.cwd, '/tmp/user-workspaces/tester')
})
test('resolveSessionPlacement prefers owner provisioned path over recent workspace when cwd empty', () => {
const recent = { id: 'ws-recent', path: '/tmp/wrong-recent', async attachSession() {} }
const ctx = {
get(name) {
if (name === 'workspaceRegistry') return { list: () => [recent] }
return undefined
},
}
const uds = mockUdsAuth({ strictPath: true })
const placed = resolveSessionPlacement(ctx, {
cwd: '',
ownerEmpNo: '10329667',
}, { udsAuth: uds })
assert.equal(placed.cwd, '/tmp/deepseek-harness/10329667')
})
test('super_admin createJob keeps explicit foreign cwd; normal user is clamped', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
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'),
udsAuthOptions: { strictPath: true },
sessionPort: {
async createAndPrompt() {
return { sessionId: 's1', status: 'succeeded', summary: 'ok' }
},
async archiveSession() {},
},
})
const superId = {
empNo: '10329667',
role: 'super_admin',
displayName: 'Super',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
const superJob = await service.createJob({
name: 'gpt-job',
prompt: 'work in gpt',
cwd: 'D:/code/gpt',
schedule: { kind: 'cron', expr: '0 2 * * *', timezone: 'Asia/Shanghai' },
}, superId)
assert.equal(superJob.cwd, 'D:/code/gpt')
assert.equal(superJob.ownerEmpNo, '10329667')
const userId = {
empNo: 'tester',
role: 'user',
displayName: 'Tester',
permissions: { canViewAllSessions: false },
workspacePath: '/tmp/user-workspaces/tester',
}
const userJob = await service.createJob({
name: 'user-job',
prompt: 'try foreign',
cwd: 'D:/code/gpt',
schedule: { kind: 'cron', expr: '0 3 * * *', timezone: 'Asia/Shanghai' },
}, userId)
assert.equal(userJob.cwd, '/tmp/user-workspaces/tester')
})
test('live fire uses super_admin job cwd outside provisioned tree', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const seen = []
const uds = mockUdsAuth({ strictPath: true })
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
getUdsAuth: () => uds,
sessionPort: {
async createAndPrompt({ job }) {
const placement = resolveSessionPlacement({
get() { return { list: () => [] } },
}, job, { udsAuth: uds })
seen.push(placement.cwd)
return { sessionId: `run-${seen.length}`, status: 'succeeded', summary: 'ok' }
},
async archiveSession() {},
},
})
const job = await service.createJob({
name: 'fire-gpt',
prompt: 'ping',
cwd: 'D:/code/gpt',
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
}, {
empNo: '10329667',
role: 'super_admin',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
})
assert.equal(job.cwd, 'D:/code/gpt')
await service.dispatchRun(job.id, 'run-now')
assert.deepEqual(seen, ['D:/code/gpt'])
})
test('IM peer create keeps bot cwd unassigned and rejects empty cwd', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
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'),
udsAuthOptions: { strictPath: true },
sessionPort: {
async createAndPrompt() {
return { sessionId: 's-im', status: 'succeeded', summary: 'ok' }
},
async archiveSession() {},
},
})
const job = await service.createJob({
name: 'im-job',
prompt: 'from channel',
cwd: '/data/bot-workspace/wa',
delivery: { kind: 'im', botId: 'bot-a', targetId: 'auto-dm' },
origin: {
kind: 'im',
sessionId: 'sess-im',
peer: { botId: 'bot-a', conversationKey: 'direct:1@s.whatsapp.net', conversationId: '1@s.whatsapp.net' },
},
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
}, {
empNo: '10329667',
permissions: { canViewAllSessions: true },
}, { fromImPeer: true })
assert.equal(job.ownerEmpNo, '__unassigned__')
assert.equal(job.cwd, '/data/bot-workspace/wa')
assert.equal(job.origin.kind, 'im')
await assert.rejects(
() => service.createJob({
name: 'im-empty',
prompt: 'x',
cwd: '',
origin: {
kind: 'im',
sessionId: 'sess-im',
peer: { botId: 'bot-a', conversationKey: 'direct:1@s.whatsapp.net' },
},
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
}, null, { fromImPeer: true }),
(error) => error.code === 'INVALID_CWD',
)
})
test('HTTP web create strips forged IM origin and rejects IM delivery for normal users', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
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() {
return { sessionId: 's', status: 'succeeded', summary: 'ok' }
},
async archiveSession() {},
},
})
const { url, close } = await listen(service)
t.after(close)
const forged = await jsonRequest(url, '/dsh-ops-cron/jobs', {
empNo: 'tester',
method: 'POST',
body: JSON.stringify({
name: 'forge',
prompt: 'x',
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
cwd: '/tmp/user-workspaces/tester',
origin: {
kind: 'im',
sessionId: 'sess-1',
peer: { botId: 'bot-a', conversationKey: 'direct:x' },
},
}),
})
assert.equal(forged.status, 200)
assert.equal(forged.body.job.ownerEmpNo, 'tester')
assert.equal(forged.body.job.origin?.kind, 'web')
const denied = await jsonRequest(url, '/dsh-ops-cron/jobs', {
empNo: 'tester',
method: 'POST',
body: JSON.stringify({
name: 'im-web',
prompt: 'x',
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
delivery: { kind: 'im', botId: 'bot-a', targetId: 't1' },
}),
})
assert.equal(denied.status, 400)
assert.equal(denied.body.code, 'IM_DELIVERY_FORBIDDEN')
})
test('resolveSessionPlacement refuses recent-workspace fallback for IM jobs without cwd', () => {
const recent = { id: 'ws-recent', path: '/tmp/wrong-recent', async attachSession() {} }
const ctx = {
get(name) {
if (name === 'workspaceRegistry') return { list: () => [recent] }
return undefined
},
}
const placed = resolveSessionPlacement(ctx, {
cwd: '',
ownerEmpNo: '__unassigned__',
origin: {
kind: 'im',
peer: { botId: 'bot-a', conversationKey: 'direct:1' },
},
})
assert.equal(placed.missingCwd, true)
assert.equal(placed.cwd, '')
})
test('adoptSessionIntoWorkspace attaches a fork whose cwd matches a workspace', async () => {
const attached = []
const workspace = {

View file

@ -14,6 +14,7 @@ import {
migrateJobOwners,
UNASSIGNED_OWNER,
viewerPayload,
preferredNewJobCwd,
} from '../lib/ownership.js'
test('jobVisibleToIdentity respects canViewAll and owner', () => {
@ -123,3 +124,15 @@ test('claimUnassignedForViewer claims by session owner or user-workspaces cwd',
// Unknown session owner: leave unassigned for admin-only visibility.
assert.equal(next.jobs[5].ownerEmpNo, UNASSIGNED_OWNER)
})
test('preferredNewJobCwd prefers provisioned viewer workspace over session cwd', () => {
assert.equal(preferredNewJobCwd({
viewer: { mode: 'multi', workspacePath: '/tmp/user-workspaces/10329667' },
sessionCwd: 'D:/code/deepseek-harness/clone',
}), '/tmp/user-workspaces/10329667')
assert.equal(preferredNewJobCwd({
viewer: { mode: 'local', workspacePath: null },
sessionCwd: '/tmp/ws-app',
}), '/tmp/ws-app')
assert.equal(preferredNewJobCwd({}), '')
})

View file

@ -40,6 +40,26 @@ test('createJobRecord defaults mirrorToSession off and accepts explicit true', (
)
})
test('createJobRecord fires ASAP when one-shot at is slightly in the past', () => {
const now = Date.parse('2026-08-24T01:00:30.000Z')
const at = new Date(now - 15_000).toISOString()
const job = createJobRecord({
name: 'soon',
prompt: 'ping',
schedule: { kind: 'at', at, timezone: 'UTC' },
}, emptyState(), now)
assert.equal(job.nextRunAt, now)
assert.throws(
() => createJobRecord({
name: 'late',
prompt: 'ping',
schedule: { kind: 'at', at: new Date(now - 120_000).toISOString(), timezone: 'UTC' },
}, emptyState(), now),
(error) => error.code === 'INVALID_AT',
)
})
test('resolveCreateAgentPreset prefers explicit then peer then session then host default', async () => {
assert.equal(
await resolveCreateAgentPreset({ agent_preset: 'explicit' }, {}, {}),

View file

@ -139,3 +139,24 @@ test('one-shot at fires once then waits as complete even if nextRunAt is null',
assert.equal(second.action, 'wait')
assert.equal(second.reason, 'complete')
})
test('one-shot misfire beyond grace skips and clears nextRunAt', () => {
const at = shanghai(2026, 8, 23, 22, 57)
const job = {
id: 'at-miss',
enabled: true,
schedule: { kind: 'at', at: new Date(at).toISOString(), timezone: TZ },
nextRunAt: at,
createdAt: at - 60_000,
}
const decision = decideDispatch({
job,
runs: [],
now: at + 5 * 60_000,
misfirePolicy: 'skip',
graceMs: 60_000,
})
assert.equal(decision.action, 'skip')
assert.equal(decision.reason, 'misfire')
assert.equal(decision.nextRunAt, null)
})

View file

@ -260,6 +260,163 @@ test('cron_create stamps ownerEmpNo from user-workspaces cwd', async (t) => {
assert.equal(listed.jobs[0].id, created.job.id)
})
test('resolveToolIdentity restores super_admin permissions via udsAuth', () => {
const uds = {
getSessionOwner(sessionId) {
return sessionId === 'sess-super' ? '10329667' : null
},
resolveIdentityForEmpNo(empNo) {
if (empNo !== '10329667') return null
return {
empNo: '10329667',
role: 'super_admin',
displayName: 'Super',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
},
getProvisionedWorkspacePath(empNo) {
return `/tmp/deepseek-harness/${empNo}`
},
}
const identity = resolveToolIdentity(
{ agent: { session: { id: 'sess-super', header: { cwd: 'D:/code/gpt' } } } },
{ getUdsAuth: () => uds },
)
assert.equal(identity.empNo, '10329667')
assert.equal(identity.role, 'super_admin')
assert.equal(identity.permissions.canViewAllSessions, true)
})
test('cron_create keeps foreign cwd for super_admin tool identity', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const owners = new Map([['sess-super', '10329667']])
const uds = {
getSessionOwner(id) { return owners.get(id) || null },
resolveIdentityForEmpNo(empNo) {
if (empNo !== '10329667') {
return {
empNo,
role: 'user',
permissions: { canViewAllSessions: false },
workspacePath: `/tmp/user-workspaces/${empNo}`,
}
}
return {
empNo: '10329667',
role: 'super_admin',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
},
getProvisionedWorkspacePath(empNo) {
return empNo === '10329667'
? '/tmp/deepseek-harness/10329667'
: `/tmp/user-workspaces/${empNo}`
},
isUserPath(empNo, candidatePath) {
const root = this.getProvisionedWorkspacePath(empNo)
const cand = String(candidatePath || '').replace(/\\/g, '/')
const normRoot = String(root).replace(/\\/g, '/')
return cand === normRoot || cand.startsWith(`${normRoot}/`)
},
}
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
getUdsAuth: () => uds,
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service))
const exec = {
agent: {
session: {
id: 'sess-super',
header: { cwd: '/tmp/deepseek-harness/10329667' },
},
},
}
const created = await tools.cron_create.execute({
name: 'gpt-cron',
prompt: 'continue BGP',
after_minutes: 1,
timezone: 'Asia/Shanghai',
cwd: 'D:/code/gpt',
}, exec)
assert.equal(created.job.cwd, 'D:/code/gpt')
assert.equal(created.job.ownerEmpNo, '10329667')
})
test('cron_create from IM peer stamps unassigned owner and forces session cwd', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const peer = {
botId: 'bot-a',
conversationKey: 'direct:86138@s.whatsapp.net',
conversationId: '86138@s.whatsapp.net',
kind: 'direct',
phone: '86138',
}
const dshIm = {
resolveSessionPeer: async () => peer,
listTargets: async () => [{
targetId: 'auto-dm-86138',
kind: 'user',
route: { jid: '86138@s.whatsapp.net' },
}],
}
const uds = {
getSessionOwner() { return '10329667' },
resolveIdentityForEmpNo(empNo) {
return {
empNo,
role: 'super_admin',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
},
getProvisionedWorkspacePath(empNo) {
return `/tmp/deepseek-harness/${empNo}`
},
isUserPath() { return false },
}
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
getUdsAuth: () => uds,
getDshIm: () => dshIm,
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service, { getDshIm: () => dshIm }))
const created = await tools.cron_create.execute({
name: 'im-cron',
prompt: 'channel work',
after_minutes: 2,
timezone: 'Asia/Shanghai',
cwd: 'D:/code/gpt',
}, {
agent: {
session: {
id: 'sess-im',
header: { cwd: '/data/bot-ws/wa' },
},
},
})
assert.equal(created.job.ownerEmpNo, '__unassigned__')
assert.equal(created.job.cwd, '/data/bot-ws/wa')
assert.equal(created.job.origin?.kind, 'im')
assert.equal(created.job.delivery?.kind, 'im')
})
test('registerCronTools registers each definition and disposer unregisters', () => {
const registered = []
const ctx = {