mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 22:00:46 +08:00
Stamp and reclaim chat-created cron jobs for the current user.
Tool creates often left ownerEmpNo unassigned so the sidebar hid them while runs still fired; infer identity from session/cwd, claim legacy orphans on list, and keep cron rows visible without leaking run sessions into the workspace list. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d298c77f3f
commit
a0dc070372
7 changed files with 254 additions and 25 deletions
|
|
@ -276,8 +276,8 @@ window.__ModuleLoader__.load({
|
|||
.dsh-ct-project:hover .dsh-ct-folder{display:none}
|
||||
.dsh-ct-arrow{transition:transform .15s var(--ds-ease-in-out,ease);display:inline-flex}
|
||||
.dsh-ct-arrowOpen{transform:rotate(90deg)}
|
||||
.dsh-ct-project,.dsh-ct-session{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;padding:0 8px;display:flex!important;box-sizing:border-box;border:none;background:transparent;width:100%;font:inherit;text-align:left}
|
||||
.dsh-ct-project{height:34px;gap:6px}
|
||||
.dsh-ct-project,.dsh-ct-session{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;padding:0 8px;display:flex;box-sizing:border-box;border:none;background:transparent;width:100%;font:inherit;text-align:left}
|
||||
.dsh-ct-project{height:34px;gap:6px;display:flex!important}
|
||||
.dsh-ct-session{height:32px;gap:0}
|
||||
.dsh-ct-project:hover,.dsh-ct-session:hover,.dsh-ct-session[data-on=true]{background:var(--dsw-alias-interactive-bg-hover)}
|
||||
.dsh-ct-project[data-paused=true] .dsh-ct-title{color:var(--dsw-alias-label-tertiary)}
|
||||
|
|
|
|||
56
lib/host.js
56
lib/host.js
|
|
@ -26,6 +26,7 @@ import {
|
|||
import {
|
||||
assertCanAccessJob,
|
||||
canViewAllJobs,
|
||||
claimUnassignedForViewer,
|
||||
filterJobsForIdentity,
|
||||
filterRunsForJobs,
|
||||
migrateJobOwners,
|
||||
|
|
@ -79,6 +80,40 @@ function parseCookieHeader(header, name) {
|
|||
}
|
||||
|
||||
|
||||
function empNoFromUserWorkspacePath(cwd) {
|
||||
const norm = String(cwd || '').replace(/\\/g, '/')
|
||||
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
function inferIdentityFromJobInput(input, getUdsAuth) {
|
||||
const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null
|
||||
if (!uds) return null
|
||||
const origin = input?.origin
|
||||
const sessionId = origin?.kind === 'web' && typeof origin.sessionId === 'string'
|
||||
? origin.sessionId.trim()
|
||||
: ''
|
||||
if (sessionId && typeof uds.getSessionOwner === 'function') {
|
||||
const empNo = uds.getSessionOwner(sessionId)
|
||||
if (empNo) {
|
||||
return {
|
||||
empNo: String(empNo),
|
||||
displayName: String(empNo),
|
||||
permissions: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
const fromCwd = empNoFromUserWorkspacePath(input?.cwd)
|
||||
if (fromCwd) {
|
||||
return {
|
||||
empNo: fromCwd,
|
||||
displayName: fromCwd,
|
||||
permissions: {},
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function browserEmpNo(request) {
|
||||
const cookie = request?.headers?.cookie || ''
|
||||
return parseCookieHeader(cookie, 'PORTALSSOUser')
|
||||
|
|
@ -137,6 +172,8 @@ function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) {
|
|||
: null
|
||||
if (!raw) return ownerPath || ''
|
||||
if (canViewAllJobs(identity)) 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 ''
|
||||
|
|
@ -274,10 +311,11 @@ export function createHostService(options = {}) {
|
|||
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 : {}
|
||||
if (identity?.empNo) {
|
||||
safeInput.ownerEmpNo = identity.empNo
|
||||
safeInput.ownerDisplayName = identity.displayName || identity.empNo
|
||||
safeInput.cwd = sanitizeJobCwd(safeInput.cwd, identity, getUdsAuth, { forOwnerEmpNo: identity.empNo })
|
||||
let ownerIdentity = identity?.empNo ? identity : inferIdentityFromJobInput(safeInput, getUdsAuth)
|
||||
if (ownerIdentity?.empNo) {
|
||||
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 {
|
||||
|
|
@ -616,7 +654,15 @@ export function createHostService(options = {}) {
|
|||
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
|
||||
const identity = await requireIdentity(req, write, getUdsAuth)
|
||||
if (!identity) return
|
||||
const state = await snapshot()
|
||||
const uds = getUdsAuth()
|
||||
const state = await withState((current) => {
|
||||
const { state: next, changed } = claimUnassignedForViewer(current, identity, {
|
||||
getSessionOwner: typeof uds?.getSessionOwner === 'function'
|
||||
? (sessionId) => uds.getSessionOwner(sessionId)
|
||||
: undefined,
|
||||
})
|
||||
return changed ? next : current
|
||||
})
|
||||
const jobs = filterJobsForIdentity(listJobs(state), identity).map(jobView)
|
||||
write(200, {
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -110,3 +110,40 @@ export function viewerPayload(identity) {
|
|||
workspacePath: identity.workspacePath || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim unassigned jobs that clearly belong to the viewer (origin session / cwd).
|
||||
* Returns { state, changed }.
|
||||
*/
|
||||
export function claimUnassignedForViewer(state, identity, deps = {}) {
|
||||
if (!identity?.empNo || canViewAllJobs(identity)) {
|
||||
return { state, changed: false }
|
||||
}
|
||||
const jobs = Array.isArray(state?.jobs) ? state.jobs : []
|
||||
let changed = false
|
||||
const nextJobs = jobs.map((job) => {
|
||||
if (!job || normalizeOwnerEmpNo(job.ownerEmpNo) !== UNASSIGNED_OWNER) return job
|
||||
const sessionId = job.origin?.kind === 'web' && typeof job.origin.sessionId === 'string'
|
||||
? job.origin.sessionId.trim()
|
||||
: ''
|
||||
let mine = false
|
||||
// Prefer explicit session ownership / user-workspaces path — do not trust a loose isUserPath.
|
||||
if (sessionId && typeof deps.getSessionOwner === 'function') {
|
||||
mine = deps.getSessionOwner(sessionId) === identity.empNo
|
||||
}
|
||||
if (!mine && job.cwd) {
|
||||
const norm = String(job.cwd).replace(/\\/g, '/')
|
||||
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
|
||||
mine = !!(match && decodeURIComponent(match[1]) === identity.empNo)
|
||||
}
|
||||
if (!mine) return job
|
||||
changed = true
|
||||
return {
|
||||
...job,
|
||||
ownerEmpNo: identity.empNo,
|
||||
ownerDisplayName: identity.displayName || identity.empNo,
|
||||
}
|
||||
})
|
||||
if (!changed) return { state, changed: false }
|
||||
return { state: { ...state, jobs: nextJobs }, changed: true }
|
||||
}
|
||||
|
|
|
|||
89
lib/tools.js
89
lib/tools.js
|
|
@ -15,6 +15,7 @@ import { resolveCreateAgentPreset } from './preset.js'
|
|||
import {
|
||||
assertCanAccessJob,
|
||||
filterJobsForIdentity,
|
||||
jobVisibleToIdentity,
|
||||
UNASSIGNED_OWNER,
|
||||
} from './ownership.js'
|
||||
|
||||
|
|
@ -127,18 +128,67 @@ export function resolveCreateCwd(args, exec, { peer = null } = {}) {
|
|||
return sessionCwd
|
||||
}
|
||||
|
||||
function forbidForeignJob(job, peer) {
|
||||
if (!peer?.botId) return job
|
||||
if (jobVisibleToPeer(job, peer)) return job
|
||||
const error = new Error('job not found or not owned by this chat')
|
||||
error.code = 'NOT_FOUND'
|
||||
throw error
|
||||
function callerSessionId(exec) {
|
||||
return String(
|
||||
exec?.agent?.session?.id
|
||||
|| exec?.agent?.session?.header?.id
|
||||
|| exec?.sessionId
|
||||
|| exec?.agent?.sessionId
|
||||
|| '',
|
||||
).trim()
|
||||
}
|
||||
|
||||
async function requireOwnedJob(service, id, peer) {
|
||||
function empNoFromUserWorkspacePath(cwd) {
|
||||
const norm = String(cwd || '').replace(/\\/g, '/')
|
||||
const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve web caller identity for ownership. IM peers keep conversation scoping.
|
||||
*/
|
||||
export function resolveToolIdentity(exec, service) {
|
||||
const uds = typeof service?.getUdsAuth === 'function' ? service.getUdsAuth() : null
|
||||
const sessionId = callerSessionId(exec)
|
||||
let empNo = null
|
||||
if (sessionId && uds?.getSessionOwner) {
|
||||
empNo = uds.getSessionOwner(sessionId)
|
||||
}
|
||||
if (!empNo) {
|
||||
empNo = empNoFromUserWorkspacePath(callerWorkingDirectory(exec))
|
||||
}
|
||||
if (!empNo) return null
|
||||
return {
|
||||
empNo: String(empNo),
|
||||
displayName: String(empNo),
|
||||
role: 'user',
|
||||
permissions: { canViewAllSessions: false },
|
||||
}
|
||||
}
|
||||
|
||||
function forbidForeignJob(job, peer, identity = null) {
|
||||
if (peer?.botId) {
|
||||
if (jobVisibleToPeer(job, peer)) return job
|
||||
const error = new Error('job not found or not owned by this chat')
|
||||
error.code = 'NOT_FOUND'
|
||||
throw error
|
||||
}
|
||||
if (identity?.empNo) {
|
||||
assertCanAccessJob(job, identity)
|
||||
return job
|
||||
}
|
||||
// No peer and no web identity: do not expose foreign jobs.
|
||||
if (job?.ownerEmpNo && job.ownerEmpNo !== UNASSIGNED_OWNER) {
|
||||
const error = new Error('job not found or not owned by this session')
|
||||
error.code = 'NOT_FOUND'
|
||||
throw error
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
async function requireOwnedJob(service, id, peer, identity = null) {
|
||||
const job = await service.getJob?.(id)
|
||||
if (job) return forbidForeignJob(job, peer)
|
||||
// Fallback when getJob is absent: list and find.
|
||||
if (job) return forbidForeignJob(job, peer, identity)
|
||||
const jobs = await service.listJobs()
|
||||
const hit = (jobs || []).find((row) => row.id === id)
|
||||
if (!hit) {
|
||||
|
|
@ -146,7 +196,7 @@ async function requireOwnedJob(service, id, peer) {
|
|||
error.code = 'NOT_FOUND'
|
||||
throw error
|
||||
}
|
||||
return forbidForeignJob(hit, peer)
|
||||
return forbidForeignJob(hit, peer, identity)
|
||||
}
|
||||
|
||||
export function callerModelSelection(exec) {
|
||||
|
|
@ -242,6 +292,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
const dshIm = getDshIm()
|
||||
try {
|
||||
const peer = await resolveCallerPeer(exec, dshIm)
|
||||
const identity = resolveToolIdentity(exec, service)
|
||||
const delivery = await resolveCreateDelivery(args, exec, { dshIm })
|
||||
const origin = await resolveCreateOrigin(args, exec, { dshIm })
|
||||
const agentPreset = await resolveCreateAgentPreset(args, exec, {
|
||||
|
|
@ -259,7 +310,7 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
delivery,
|
||||
...origin ? { origin } : {},
|
||||
agentPreset,
|
||||
})
|
||||
}, identity)
|
||||
return { job }
|
||||
} catch (error) {
|
||||
const tz = args.timezone || args.time_zone || 'Asia/Shanghai'
|
||||
|
|
@ -302,7 +353,8 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||
let jobs = await service.listJobs()
|
||||
const identity = resolveToolIdentity(exec, service)
|
||||
let jobs = await service.listJobs(identity)
|
||||
if (peer?.botId) jobs = jobs.filter((job) => jobVisibleToPeer(job, peer))
|
||||
if (args?.enabled_only === true) jobs = jobs.filter((job) => job.enabled !== false)
|
||||
return { jobs, count: jobs.length }
|
||||
|
|
@ -325,9 +377,10 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||
const identity = resolveToolIdentity(exec, service)
|
||||
const id = requireId(args)
|
||||
await requireOwnedJob(service, id, peer)
|
||||
const job = await service.pauseJob(id, false)
|
||||
await requireOwnedJob(service, id, peer, identity)
|
||||
const job = await service.pauseJob(id, false, identity)
|
||||
return { job }
|
||||
},
|
||||
},
|
||||
|
|
@ -348,9 +401,10 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||
const identity = resolveToolIdentity(exec, service)
|
||||
const id = requireId(args)
|
||||
await requireOwnedJob(service, id, peer)
|
||||
const job = await service.pauseJob(id, true)
|
||||
await requireOwnedJob(service, id, peer, identity)
|
||||
const job = await service.pauseJob(id, true, identity)
|
||||
return { job }
|
||||
},
|
||||
},
|
||||
|
|
@ -379,9 +433,10 @@ export function cronToolDefinitions(service, deps = {}) {
|
|||
async execute(args, exec) {
|
||||
aborted(exec)
|
||||
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||
const identity = resolveToolIdentity(exec, service)
|
||||
const id = requireId(args)
|
||||
try {
|
||||
await requireOwnedJob(service, id, peer)
|
||||
await requireOwnedJob(service, id, peer, identity)
|
||||
await service.deleteJob(id)
|
||||
return { id, deleted: true }
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -865,6 +865,57 @@ test('host HTTP: per-user job isolation and super sees all', async (t) => {
|
|||
assert.equal(listTester2.body.jobs.length, 2)
|
||||
})
|
||||
|
||||
test('GET /jobs claims unassigned jobs that match viewer cwd', async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-claim-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const service = createTestHost({
|
||||
filePath: join(dir, 'store.json'),
|
||||
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||
})
|
||||
const mine = await service.createJob({
|
||||
name: 'orphan-mine',
|
||||
prompt: 'x',
|
||||
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
|
||||
cwd: '/tmp/plain-a',
|
||||
})
|
||||
const other = await service.createJob({
|
||||
name: 'orphan-other',
|
||||
prompt: 'y',
|
||||
schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' },
|
||||
cwd: '/tmp/plain-b',
|
||||
})
|
||||
// Simulate legacy rows: unassigned but cwd clearly under the viewer's workspace.
|
||||
await service.store.mutate((current) => ({
|
||||
...current,
|
||||
jobs: current.jobs.map((job) => {
|
||||
if (job.id === mine.id) {
|
||||
return {
|
||||
...job,
|
||||
ownerEmpNo: '__unassigned__',
|
||||
ownerDisplayName: '',
|
||||
cwd: '/tmp/user-workspaces/tester/ws',
|
||||
}
|
||||
}
|
||||
if (job.id === other.id) {
|
||||
return {
|
||||
...job,
|
||||
ownerEmpNo: '__unassigned__',
|
||||
ownerDisplayName: '',
|
||||
cwd: '/tmp/user-workspaces/peer/ws',
|
||||
}
|
||||
}
|
||||
return job
|
||||
}),
|
||||
}))
|
||||
const http = await listen(service)
|
||||
t.after(() => http.close())
|
||||
const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'tester' })
|
||||
assert.equal(listed.status, 200)
|
||||
assert.equal(listed.body.jobs.length, 1)
|
||||
assert.equal(listed.body.jobs[0].name, 'orphan-mine')
|
||||
assert.equal(listed.body.jobs[0].ownerEmpNo, 'tester')
|
||||
})
|
||||
|
||||
test('migrateJobOwners assigns unassigned for legacy jobs', async () => {
|
||||
const { migrateJobOwners, UNASSIGNED_OWNER } = await import('../lib/ownership.js')
|
||||
const state = {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { test } from 'node:test'
|
|||
import {
|
||||
assertCanAccessJob,
|
||||
canViewAllJobs,
|
||||
claimUnassignedForViewer,
|
||||
filterJobsForIdentity,
|
||||
filterRunsForJobs,
|
||||
inferOwnerEmpNo,
|
||||
|
|
@ -72,3 +73,23 @@ test('viewerPayload', () => {
|
|||
assert.equal(v.canViewAll, false)
|
||||
assert.equal(v.workspacePath, '/w/u1')
|
||||
})
|
||||
|
||||
test('claimUnassignedForViewer claims by session owner or user-workspaces cwd', () => {
|
||||
const user = { empNo: 'u1', displayName: 'U1', permissions: { canViewAllSessions: false } }
|
||||
const state = {
|
||||
jobs: [
|
||||
{ id: 'a', ownerEmpNo: UNASSIGNED_OWNER, origin: { kind: 'web', sessionId: 's1' }, cwd: '/tmp/other' },
|
||||
{ id: 'b', ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u1/proj' },
|
||||
{ id: 'c', ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u2/proj' },
|
||||
{ id: 'd', ownerEmpNo: 'u2', cwd: '/data/user-workspaces/u1/x' },
|
||||
],
|
||||
}
|
||||
const { changed, state: next } = claimUnassignedForViewer(state, user, {
|
||||
getSessionOwner: (id) => (id === 's1' ? 'u1' : null),
|
||||
})
|
||||
assert.equal(changed, true)
|
||||
assert.equal(next.jobs[0].ownerEmpNo, 'u1')
|
||||
assert.equal(next.jobs[1].ownerEmpNo, 'u1')
|
||||
assert.equal(next.jobs[2].ownerEmpNo, UNASSIGNED_OWNER)
|
||||
assert.equal(next.jobs[3].ownerEmpNo, 'u2')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { createHostService } from '../lib/host.js'
|
||||
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from '../lib/tools.js'
|
||||
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, resolveCreateModel, resolveToolIdentity, scheduleFromArgs } from '../lib/tools.js'
|
||||
|
||||
async function makeService(t) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
|
||||
|
|
@ -241,6 +241,25 @@ test('registerCronGuidance registers the scheduled-tasks skill only', () => {
|
|||
assert.equal(skills.length, 0)
|
||||
})
|
||||
|
||||
test('cron_create stamps ownerEmpNo from user-workspaces cwd', async (t) => {
|
||||
const service = await makeService(t)
|
||||
const tools = byName(cronToolDefinitions(service))
|
||||
const exec = { agent: { session: { header: { cwd: '/tmp/user-workspaces/alice/app' } } } }
|
||||
assert.equal(resolveToolIdentity(exec, service)?.empNo, 'alice')
|
||||
const created = await tools.cron_create.execute({
|
||||
name: 'owned',
|
||||
prompt: 'ping',
|
||||
after_minutes: 5,
|
||||
timezone: 'Asia/Shanghai',
|
||||
}, exec)
|
||||
assert.equal(created.job.ownerEmpNo, 'alice')
|
||||
assert.equal(created.job.cwd, '/tmp/user-workspaces/alice/app')
|
||||
|
||||
const listed = await tools.cron_list.execute({}, exec)
|
||||
assert.equal(listed.count, 1)
|
||||
assert.equal(listed.jobs[0].id, created.job.id)
|
||||
})
|
||||
|
||||
test('registerCronTools registers each definition and disposer unregisters', () => {
|
||||
const registered = []
|
||||
const ctx = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue