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

@ -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)