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
90
lib/host.js
90
lib/host.js
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
10
lib/store.js
10
lib/store.js
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue