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
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue