Keep shared external cron cwd (e.g. D:\code\gpt) instead of silent clamp.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 11:23:41 +08:00
parent 7a2a673ac2
commit 43facd608a
5 changed files with 111 additions and 14 deletions

View file

@ -6,7 +6,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises' import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { basename, join } from 'node:path' import { basename, dirname, join } from 'node:path'
import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js' import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js'
import { assertDeliveryAllowedForIdentity, deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeDelivery, normalizeOrigin } from './delivery.js' import { assertDeliveryAllowedForIdentity, deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeDelivery, normalizeOrigin } from './delivery.js'
import { apiError, resolveLocale } from './i18n.js' import { apiError, resolveLocale } from './i18n.js'
@ -190,6 +190,9 @@ function allowsForeignJobCwd(identity, getUdsAuth, ownerEmpNo) {
const live = uds.resolveIdentityForEmpNo(empNo) const live = uds.resolveIdentityForEmpNo(empNo)
if (live && canViewAllJobs(live)) return true if (live && canViewAllJobs(live)) return true
} }
if (typeof uds.getRole === 'function' && isElevatedCronRole(uds.getRole(empNo))) return true
// Match dsh-acl: local fallback cookie user is always elevated.
if (empNo === 'administrator') return true
return false return false
} }
@ -205,6 +208,7 @@ function enrichIdentity(identity, getUdsAuth) {
|| identity.permissions?.canViewAllSessions || identity.permissions?.canViewAllSessions
|| isElevatedCronRole(live.role) || isElevatedCronRole(live.role)
|| isElevatedCronRole(identity.role) || isElevatedCronRole(identity.role)
|| identity.empNo === 'administrator'
) )
return { return {
...identity, ...identity,
@ -224,7 +228,7 @@ function enrichIdentity(identity, getUdsAuth) {
} }
} }
} }
if (isElevatedCronRole(identity.role)) { if (isElevatedCronRole(identity.role) || identity.empNo === 'administrator') {
return { return {
...identity, ...identity,
permissions: { permissions: {
@ -237,7 +241,25 @@ function enrichIdentity(identity, getUdsAuth) {
return identity return identity
} }
function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { export function normalizeFsPath(value) {
return String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '')
}
export function isPathInside(candidate, root) {
const cand = normalizeFsPath(candidate).toLowerCase()
const base = normalizeFsPath(root).toLowerCase()
if (!cand || !base) return false
return cand === base || cand.startsWith(`${base}/`)
}
/**
* Decide the durable cwd for a job.
* - Elevated owners: any explicit cwd
* - Owner tree (isUserPath): keep
* - Inside provision forest but not owner tree: clamp to ownerPath
* - Outside forest (shared trees like D:\\code\\gpt): keep when allowExternalCwd
*/
function resolveSanitizedCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo, allowExternalCwd = true } = {}) {
const raw = typeof cwd === 'string' ? cwd.trim() : '' const raw = typeof cwd === 'string' ? cwd.trim() : ''
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
const owner = forOwnerEmpNo || identity?.empNo const owner = forOwnerEmpNo || identity?.empNo
@ -246,11 +268,36 @@ function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) {
: null : null
if (!raw) return ownerPath || '' if (!raw) return ownerPath || ''
if (allowsForeignJobCwd(identity, getUdsAuth, owner)) return raw if (allowsForeignJobCwd(identity, getUdsAuth, owner)) return raw
// Without uds-auth, keep the caller cwd (tools may stamp owner from path only).
if (!uds) return raw if (!uds) return raw
if (owner && uds?.isUserPath?.(owner, raw)) return raw if (owner && uds?.isUserPath?.(owner, raw)) return raw
if (ownerPath) return ownerPath if (ownerPath) {
return '' const forest = dirname(ownerPath)
if (forest && isPathInside(raw, forest)) return ownerPath
if (allowExternalCwd !== false) return raw
return ownerPath
}
return allowExternalCwd !== false ? raw : ''
}
function sanitizeJobCwd(cwd, identity, getUdsAuth, opts = {}) {
const raw = typeof cwd === 'string' ? cwd.trim() : ''
const effective = resolveSanitizedCwd(cwd, identity, getUdsAuth, opts)
if (
raw
&& normalizeFsPath(raw).toLowerCase() !== normalizeFsPath(effective).toLowerCase()
&& opts.rejectClamp
) {
const error = new Error(
`cwd "${raw}" is not allowed for this user (effective "${effective || '(empty)'}"). `
+ 'Use a path under your provisioned workspace, a shared tree outside workspaceRoot '
+ '(when allowExternalCwd is on), or a super_admin / fallback_admin account.',
)
error.code = 'CWD_FORBIDDEN'
error.requestedCwd = raw
error.effectiveCwd = effective
throw error
}
return effective
} }
function isTrustedApiRequest(request) { function isTrustedApiRequest(request) {
@ -424,7 +471,14 @@ export function createHostService(options = {}) {
safeInput.delivery = delivery safeInput.delivery = delivery
safeInput.ownerEmpNo = ownerIdentity.empNo safeInput.ownerEmpNo = ownerIdentity.empNo
safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo }) const snap = await snapshot()
const allowExternalCwd = snap?.settings?.allowExternalCwd !== false
const requestedCwd = String(safeInput.cwd || '').trim()
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, {
forOwnerEmpNo: ownerIdentity.empNo,
allowExternalCwd,
rejectClamp: !!requestedCwd,
})
} else if (safeInput.ownerEmpNo) { } else if (safeInput.ownerEmpNo) {
safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo) safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo)
} else { } else {
@ -482,11 +536,17 @@ export function createHostService(options = {}) {
nextInput.delivery = mergeDeliveryMention(job.delivery, patch.delivery) nextInput.delivery = mergeDeliveryMention(job.delivery, patch.delivery)
assertDeliveryAllowedForIdentity(nextInput.delivery, enriched) assertDeliveryAllowedForIdentity(nextInput.delivery, enriched)
} }
const allowExternalCwd = current.settings?.allowExternalCwd !== false
const requestedCwd = patch.cwd !== undefined ? String(patch.cwd || '').trim() : ''
nextInput.cwd = sanitizeJobCwd( nextInput.cwd = sanitizeJobCwd(
nextInput.cwd, nextInput.cwd,
enriched, enriched,
getUdsAuth, getUdsAuth,
{ forOwnerEmpNo: nextInput.ownerEmpNo }, {
forOwnerEmpNo: nextInput.ownerEmpNo,
allowExternalCwd,
rejectClamp: !!requestedCwd,
},
) )
} }
if (patch.ownerEmpNo !== undefined && canViewAllJobs(enrichIdentity(patch._identity, getUdsAuth))) { if (patch.ownerEmpNo !== undefined && canViewAllJobs(enrichIdentity(patch._identity, getUdsAuth))) {
@ -1089,7 +1149,8 @@ export function ownerAllowsForeignCwd(ownerEmpNo, uds) {
/** /**
* Placement order: * Placement order:
* 1. Explicit job.cwd (kept for super_admin owners; clamped to owner tree for users) * 1. Explicit job.cwd (kept for elevated owners; shared trees outside forest kept when allowExternalCwd;
* clamped to owner tree only when inside provision forest but not the owner's path)
* 2. Owner provisioned path (multi-user) * 2. Owner provisioned path (multi-user)
* 3. Recent registry workspace — only standalone / non-IM unassigned * 3. Recent registry workspace — only standalone / non-IM unassigned
* 4. Shared ops-cron fallback * 4. Shared ops-cron fallback
@ -1105,12 +1166,19 @@ export function resolveSessionPlacement(ctx, job = {}, deps = {}) {
? uds.getProvisionedWorkspacePath(ownerEmpNo) ? uds.getProvisionedWorkspacePath(ownerEmpNo)
: null : null
const imOrigin = normalizeOrigin(job?.origin)?.kind === 'im' const imOrigin = normalizeOrigin(job?.origin)?.kind === 'im'
const allowExternalCwd = deps.allowExternalCwd !== false
let requested = String(job?.cwd || '').trim() let requested = String(job?.cwd || '').trim()
if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) { if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) {
const allowForeign = ownerAllowsForeignCwd(ownerEmpNo, uds) const allowForeign = ownerAllowsForeignCwd(ownerEmpNo, uds)
if (!uds.isUserPath(ownerEmpNo, requested) && !allowForeign && ownerPath) { if (!uds.isUserPath(ownerEmpNo, requested) && !allowForeign && ownerPath) {
requested = ownerPath const forest = dirname(ownerPath)
if (forest && isPathInside(requested, forest)) {
requested = ownerPath
} else if (allowExternalCwd === false) {
requested = ownerPath
}
// else: outside forest → keep requested (shared business cwd)
} }
} }
if (requested) return { cwd: requested, workspace: match(requested), missingCwd: false } if (requested) return { cwd: requested, workspace: match(requested), missingCwd: false }
@ -1390,7 +1458,7 @@ export function makeLiveSessionPort(ctx) {
} }
const sessionId = randomUUID() const sessionId = randomUUID()
const udsAuth = tryGet(ctx, 'udsAuth') const udsAuth = tryGet(ctx, 'udsAuth')
const placement = resolveSessionPlacement(ctx, job, { udsAuth }) const placement = resolveSessionPlacement(ctx, job, { udsAuth, allowExternalCwd: true })
if (placement.missingCwd) { if (placement.missingCwd) {
const error = new Error('IM scheduled job has no cwd; recreate it from the channel chat') const error = new Error('IM scheduled job has no cwd; recreate it from the channel chat')
error.code = 'IM_JOB_MISSING_CWD' error.code = 'IM_JOB_MISSING_CWD'

View file

@ -115,6 +115,7 @@ export const Config = Schema.object({
historyLimit: Schema.number().min(10).max(2000).step(1).default(DEFAULT_SETTINGS.historyLimit), historyLimit: Schema.number().min(10).max(2000).step(1).default(DEFAULT_SETTINGS.historyLimit),
overlapPolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.overlapPolicy), overlapPolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.overlapPolicy),
misfirePolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.misfirePolicy), misfirePolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.misfirePolicy),
allowExternalCwd: Schema.boolean().default(DEFAULT_SETTINGS.allowExternalCwd),
}) })
function resolveConfig(config = {}) { function resolveConfig(config = {}) {
@ -126,6 +127,7 @@ function resolveConfig(config = {}) {
historyLimit: Number(config.historyLimit) > 0 ? Number(config.historyLimit) : DEFAULT_SETTINGS.historyLimit, historyLimit: Number(config.historyLimit) > 0 ? Number(config.historyLimit) : DEFAULT_SETTINGS.historyLimit,
overlapPolicy: config.overlapPolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.overlapPolicy, overlapPolicy: config.overlapPolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.overlapPolicy,
misfirePolicy: config.misfirePolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.misfirePolicy, misfirePolicy: config.misfirePolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.misfirePolicy,
allowExternalCwd: config.allowExternalCwd !== false,
} }
} }

View file

@ -22,6 +22,12 @@ export const DEFAULT_SETTINGS = {
historyLimit: 200, historyLimit: 200,
overlapPolicy: 'skip', overlapPolicy: 'skip',
misfirePolicy: 'skip', misfirePolicy: 'skip',
/**
* When true (default), an explicit job cwd that lies OUTSIDE the multi-tenant
* provision forest (workspaceRoot/<empNo>) is kept — e.g. D:\\code\\gpt.
* Paths under the forest but not the owner's tree are still clamped.
*/
allowExternalCwd: true,
} }
export function emptyState() { export function emptyState() {
@ -37,6 +43,9 @@ export function emptyState() {
export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) { export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) {
const src = input && typeof input === 'object' ? input : {} const src = input && typeof input === 'object' ? input : {}
const historyLimit = Number(src.historyLimit) const historyLimit = Number(src.historyLimit)
const allowExternalCwd = src.allowExternalCwd !== undefined
? src.allowExternalCwd !== false
: fallback.allowExternalCwd !== false
return { return {
enabled: src.enabled !== false, enabled: src.enabled !== false,
timezone: typeof src.timezone === 'string' && src.timezone.trim() timezone: typeof src.timezone === 'string' && src.timezone.trim()
@ -47,6 +56,7 @@ export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) {
: fallback.historyLimit, : fallback.historyLimit,
overlapPolicy: src.overlapPolicy === 'skip' ? 'skip' : fallback.overlapPolicy, overlapPolicy: src.overlapPolicy === 'skip' ? 'skip' : fallback.overlapPolicy,
misfirePolicy: src.misfirePolicy === 'skip' ? 'skip' : fallback.misfirePolicy, misfirePolicy: src.misfirePolicy === 'skip' ? 'skip' : fallback.misfirePolicy,
allowExternalCwd,
} }
} }

View file

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

View file

@ -603,10 +603,16 @@ test('resolveSessionPlacement keeps foreign cwd for super_admin owner under stri
assert.equal(kept.cwd, 'D:/code/gpt') assert.equal(kept.cwd, 'D:/code/gpt')
const clamped = resolveSessionPlacement(ctx, { const clamped = resolveSessionPlacement(ctx, {
cwd: 'D:/code/gpt', cwd: '/tmp/user-workspaces/peer/x',
ownerEmpNo: 'tester', ownerEmpNo: 'tester',
}, { udsAuth: uds }) }, { udsAuth: uds })
assert.equal(clamped.cwd, '/tmp/user-workspaces/tester') assert.equal(clamped.cwd, '/tmp/user-workspaces/tester')
const external = resolveSessionPlacement(ctx, {
cwd: 'D:/code/gpt',
ownerEmpNo: 'tester',
}, { udsAuth: uds })
assert.equal(external.cwd, 'D:/code/gpt')
}) })
test('resolveSessionPlacement prefers owner provisioned path over recent workspace when cwd empty', () => { test('resolveSessionPlacement prefers owner provisioned path over recent workspace when cwd empty', () => {
@ -668,7 +674,18 @@ test('super_admin createJob keeps explicit foreign cwd; normal user is clamped',
cwd: 'D:/code/gpt', cwd: 'D:/code/gpt',
schedule: { kind: 'cron', expr: '0 3 * * *', timezone: 'Asia/Shanghai' }, schedule: { kind: 'cron', expr: '0 3 * * *', timezone: 'Asia/Shanghai' },
}, userId) }, userId)
assert.equal(userJob.cwd, '/tmp/user-workspaces/tester') // Shared tree outside provision forest is kept (allowExternalCwd default).
assert.equal(userJob.cwd, 'D:/code/gpt')
await assert.rejects(
() => service.createJob({
name: 'steal',
prompt: 'no',
cwd: '/tmp/user-workspaces/peer/secret',
schedule: { kind: 'cron', expr: '0 4 * * *', timezone: 'Asia/Shanghai' },
}, userId),
(err) => err && err.code === 'CWD_FORBIDDEN',
)
}) })
test('createJob elevates via live resolveIdentityForEmpNo when caller permissions are empty', async (t) => { test('createJob elevates via live resolveIdentityForEmpNo when caller permissions are empty', async (t) => {