mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 22:00:46 +08:00
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:
parent
7a2a673ac2
commit
43facd608a
5 changed files with 111 additions and 14 deletions
88
lib/host.js
88
lib/host.js
|
|
@ -6,7 +6,7 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
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 { assertDeliveryAllowedForIdentity, deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeDelivery, normalizeOrigin } from './delivery.js'
|
||||
import { apiError, resolveLocale } from './i18n.js'
|
||||
|
|
@ -190,6 +190,9 @@ function allowsForeignJobCwd(identity, getUdsAuth, ownerEmpNo) {
|
|||
const live = uds.resolveIdentityForEmpNo(empNo)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +208,7 @@ function enrichIdentity(identity, getUdsAuth) {
|
|||
|| identity.permissions?.canViewAllSessions
|
||||
|| isElevatedCronRole(live.role)
|
||||
|| isElevatedCronRole(identity.role)
|
||||
|| identity.empNo === 'administrator'
|
||||
)
|
||||
return {
|
||||
...identity,
|
||||
|
|
@ -224,7 +228,7 @@ function enrichIdentity(identity, getUdsAuth) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (isElevatedCronRole(identity.role)) {
|
||||
if (isElevatedCronRole(identity.role) || identity.empNo === 'administrator') {
|
||||
return {
|
||||
...identity,
|
||||
permissions: {
|
||||
|
|
@ -237,7 +241,25 @@ function enrichIdentity(identity, getUdsAuth) {
|
|||
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 uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
|
||||
const owner = forOwnerEmpNo || identity?.empNo
|
||||
|
|
@ -246,11 +268,36 @@ function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) {
|
|||
: null
|
||||
if (!raw) return ownerPath || ''
|
||||
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 (owner && uds?.isUserPath?.(owner, raw)) return raw
|
||||
if (ownerPath) return ownerPath
|
||||
return ''
|
||||
if (ownerPath) {
|
||||
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) {
|
||||
|
|
@ -424,7 +471,14 @@ export function createHostService(options = {}) {
|
|||
safeInput.delivery = delivery
|
||||
safeInput.ownerEmpNo = 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) {
|
||||
safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo)
|
||||
} else {
|
||||
|
|
@ -482,11 +536,17 @@ export function createHostService(options = {}) {
|
|||
nextInput.delivery = mergeDeliveryMention(job.delivery, patch.delivery)
|
||||
assertDeliveryAllowedForIdentity(nextInput.delivery, enriched)
|
||||
}
|
||||
const allowExternalCwd = current.settings?.allowExternalCwd !== false
|
||||
const requestedCwd = patch.cwd !== undefined ? String(patch.cwd || '').trim() : ''
|
||||
nextInput.cwd = sanitizeJobCwd(
|
||||
nextInput.cwd,
|
||||
enriched,
|
||||
getUdsAuth,
|
||||
{ forOwnerEmpNo: nextInput.ownerEmpNo },
|
||||
{
|
||||
forOwnerEmpNo: nextInput.ownerEmpNo,
|
||||
allowExternalCwd,
|
||||
rejectClamp: !!requestedCwd,
|
||||
},
|
||||
)
|
||||
}
|
||||
if (patch.ownerEmpNo !== undefined && canViewAllJobs(enrichIdentity(patch._identity, getUdsAuth))) {
|
||||
|
|
@ -1089,7 +1149,8 @@ export function ownerAllowsForeignCwd(ownerEmpNo, uds) {
|
|||
|
||||
/**
|
||||
* 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)
|
||||
* 3. Recent registry workspace — only standalone / non-IM unassigned
|
||||
* 4. Shared ops-cron fallback
|
||||
|
|
@ -1105,12 +1166,19 @@ export function resolveSessionPlacement(ctx, job = {}, deps = {}) {
|
|||
? uds.getProvisionedWorkspacePath(ownerEmpNo)
|
||||
: null
|
||||
const imOrigin = normalizeOrigin(job?.origin)?.kind === 'im'
|
||||
const allowExternalCwd = deps.allowExternalCwd !== false
|
||||
|
||||
let requested = String(job?.cwd || '').trim()
|
||||
if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) {
|
||||
const allowForeign = ownerAllowsForeignCwd(ownerEmpNo, uds)
|
||||
if (!uds.isUserPath(ownerEmpNo, requested) && !allowForeign && 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 }
|
||||
|
|
@ -1390,7 +1458,7 @@ export function makeLiveSessionPort(ctx) {
|
|||
}
|
||||
const sessionId = randomUUID()
|
||||
const udsAuth = tryGet(ctx, 'udsAuth')
|
||||
const placement = resolveSessionPlacement(ctx, job, { udsAuth })
|
||||
const placement = resolveSessionPlacement(ctx, job, { udsAuth, allowExternalCwd: true })
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export const Config = Schema.object({
|
|||
historyLimit: Schema.number().min(10).max(2000).step(1).default(DEFAULT_SETTINGS.historyLimit),
|
||||
overlapPolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.overlapPolicy),
|
||||
misfirePolicy: Schema.union(['skip']).default(DEFAULT_SETTINGS.misfirePolicy),
|
||||
allowExternalCwd: Schema.boolean().default(DEFAULT_SETTINGS.allowExternalCwd),
|
||||
})
|
||||
|
||||
function resolveConfig(config = {}) {
|
||||
|
|
@ -126,6 +127,7 @@ function resolveConfig(config = {}) {
|
|||
historyLimit: Number(config.historyLimit) > 0 ? Number(config.historyLimit) : DEFAULT_SETTINGS.historyLimit,
|
||||
overlapPolicy: config.overlapPolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.overlapPolicy,
|
||||
misfirePolicy: config.misfirePolicy === 'skip' ? 'skip' : DEFAULT_SETTINGS.misfirePolicy,
|
||||
allowExternalCwd: config.allowExternalCwd !== false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
10
lib/store.js
10
lib/store.js
|
|
@ -22,6 +22,12 @@ export const DEFAULT_SETTINGS = {
|
|||
historyLimit: 200,
|
||||
overlapPolicy: '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() {
|
||||
|
|
@ -37,6 +43,9 @@ export function emptyState() {
|
|||
export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) {
|
||||
const src = input && typeof input === 'object' ? input : {}
|
||||
const historyLimit = Number(src.historyLimit)
|
||||
const allowExternalCwd = src.allowExternalCwd !== undefined
|
||||
? src.allowExternalCwd !== false
|
||||
: fallback.allowExternalCwd !== false
|
||||
return {
|
||||
enabled: src.enabled !== false,
|
||||
timezone: typeof src.timezone === 'string' && src.timezone.trim()
|
||||
|
|
@ -47,6 +56,7 @@ export function normalizeSettings(input = {}, fallback = DEFAULT_SETTINGS) {
|
|||
: fallback.historyLimit,
|
||||
overlapPolicy: src.overlapPolicy === 'skip' ? 'skip' : fallback.overlapPolicy,
|
||||
misfirePolicy: src.misfirePolicy === 'skip' ? 'skip' : fallback.misfirePolicy,
|
||||
allowExternalCwd,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.14",
|
||||
"version": "0.1.15",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -603,10 +603,16 @@ test('resolveSessionPlacement keeps foreign cwd for super_admin owner under stri
|
|||
assert.equal(kept.cwd, 'D:/code/gpt')
|
||||
|
||||
const clamped = resolveSessionPlacement(ctx, {
|
||||
cwd: 'D:/code/gpt',
|
||||
cwd: '/tmp/user-workspaces/peer/x',
|
||||
ownerEmpNo: 'tester',
|
||||
}, { udsAuth: uds })
|
||||
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', () => {
|
||||
|
|
@ -668,7 +674,18 @@ test('super_admin createJob keeps explicit foreign cwd; normal user is clamped',
|
|||
cwd: 'D:/code/gpt',
|
||||
schedule: { kind: 'cron', expr: '0 3 * * *', timezone: 'Asia/Shanghai' },
|
||||
}, 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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue