mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 03:03:18 +08:00
Fix cron cwd for super_admin and harden IM channel job isolation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b35bfde557
commit
b1bb0067a0
16 changed files with 714 additions and 49 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
lib/fire.js
15
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'
|
||||
|
|
|
|||
126
lib/host.js
126
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)
|
||||
|
|
|
|||
3
lib/index.d.ts
vendored
3
lib/index.d.ts
vendored
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }.
|
||||
|
|
|
|||
14
lib/store.js
14
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
|
||||
}
|
||||
|
|
|
|||
40
lib/tools.js
40
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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue