From b1bb0067a003eaff6bb72e64edb5ce246300d138 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 10:00:59 +0800 Subject: [PATCH] Fix cron cwd for super_admin and harden IM channel job isolation. Co-authored-by: Cursor --- lib/client.js | 32 +++-- lib/delivery.js | 20 +++ lib/fire.js | 15 ++- lib/host.js | 126 +++++++++++++++---- lib/index.d.ts | 3 + lib/index.js | 5 +- lib/ownership.js | 12 ++ lib/store.js | 14 ++- lib/tools.js | 40 +++++- package.json | 2 +- test/delivery.test.js | 15 +++ test/host.test.js | 268 ++++++++++++++++++++++++++++++++++++++++- test/ownership.test.js | 13 ++ test/preset.test.js | 20 +++ test/scheduler.test.js | 21 ++++ test/tools.test.js | 157 ++++++++++++++++++++++++ 16 files changed, 714 insertions(+), 49 deletions(-) diff --git a/lib/client.js b/lib/client.js index 16efa41..b5e4107 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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) } diff --git a/lib/delivery.js b/lib/delivery.js index 5dd7d31..b58495e 100644 --- a/lib/delivery.js +++ b/lib/delivery.js @@ -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 diff --git a/lib/fire.js b/lib/fire.js index fcb2d63..21a6fe9 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -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' diff --git a/lib/host.js b/lib/host.js index 4f6f3be..a9d68cd 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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) diff --git a/lib/index.d.ts b/lib/index.d.ts index 981ee96..560bba0 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -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 }> diff --git a/lib/index.js b/lib/index.js index 94e59de..5e86c87 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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, diff --git a/lib/ownership.js b/lib/ownership.js index b29edc7..ed06be4 100644 --- a/lib/ownership.js +++ b/lib/ownership.js @@ -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 }. diff --git a/lib/store.js b/lib/store.js index b53fcb7..c1d5e33 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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 } diff --git a/lib/tools.js b/lib/tools.js index aa3103f..4d003ab 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -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' diff --git a/package.json b/package.json index 0823b30..b8e4055 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/test/delivery.test.js b/test/delivery.test.js index 1cf7153..6a1757d 100644 --- a/test/delivery.test.js +++ b/test/delivery.test.js @@ -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: {} })) +}) diff --git a/test/host.test.js b/test/host.test.js index d08b1f9..89962c5 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -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 = { diff --git a/test/ownership.test.js b/test/ownership.test.js index fbc6af3..9598ebf 100644 --- a/test/ownership.test.js +++ b/test/ownership.test.js @@ -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({}), '') +}) diff --git a/test/preset.test.js b/test/preset.test.js index 5113260..70a52c3 100644 --- a/test/preset.test.js +++ b/test/preset.test.js @@ -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' }, {}, {}), diff --git a/test/scheduler.test.js b/test/scheduler.test.js index 3c91635..8bd946e 100644 --- a/test/scheduler.test.js +++ b/test/scheduler.test.js @@ -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) +}) diff --git a/test/tools.test.js b/test/tools.test.js index 721ca9a..f67473e 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -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 = {