dsh-ops-cron/test/tools.test.js
oliver 8cf624ce7f Add sidebar run permission preset for scheduled jobs.
Default inherits Host new-session access mode; humans can raise to workspace-write or full access. Agents never see or set the field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:08:39 +08:00

569 lines
20 KiB
JavaScript

import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
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, resolveToolIdentity, scheduleFromArgs } from '../lib/tools.js'
async function makeService(t) {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
return createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
}
function byName(defs) {
return Object.fromEntries(defs.map((row) => [row.name, row]))
}
test('cron_create then cron_list round-trip, pause and delete', async (t) => {
const service = await makeService(t)
const tools = byName(cronToolDefinitions(service))
const created = await tools.cron_create.execute({
name: '晨间摘要',
prompt: 'Summarize overnight CI.',
expr: '0 9 * * 1-5',
timezone: 'Asia/Shanghai',
}, {})
assert.equal(created.job.name, '晨间摘要')
assert.equal(created.job.schedule.kind, 'cron')
assert.equal(created.job.schedule.expr, '0 9 * * 1-5')
assert.ok(created.job.id)
const listed = await tools.cron_list.execute({}, {})
assert.equal(listed.count, 1)
assert.equal(listed.jobs[0].id, created.job.id)
const paused = await tools.cron_pause.execute({ id: created.job.id }, {})
assert.equal(paused.job.enabled, false)
const resumed = await tools.cron_resume.execute({ id: created.job.id }, {})
assert.equal(resumed.job.enabled, true)
const deleted = await tools.cron_delete.execute({ id: created.job.id }, {})
assert.deepEqual(deleted, { id: created.job.id, deleted: true })
const empty = await tools.cron_list.execute({}, {})
assert.equal(empty.count, 0)
})
test('cron_create cwd defaults to the calling session workspace path', async (t) => {
const service = await makeService(t)
const tools = byName(cronToolDefinitions(service))
const exec = { agent: { session: { header: { cwd: '/tmp/ws-app' } } } }
const created = await tools.cron_create.execute({
name: 'from-chat',
prompt: 'do the thing',
hour: 23,
minute: 0,
timezone: 'Asia/Shanghai',
}, exec)
assert.equal(created.job.cwd, '/tmp/ws-app')
const explicit = await tools.cron_create.execute({
name: 'custom-cwd',
prompt: 'do the thing',
hour: 23,
minute: 1,
timezone: 'Asia/Shanghai',
cwd: '/tmp/isolated',
}, exec)
assert.equal(explicit.job.cwd, '/tmp/isolated')
})
test('cron_create snapshots the calling session model when provider is omitted', async (t) => {
const service = await makeService(t)
const tools = byName(cronToolDefinitions(service))
const created = await tools.cron_create.execute({
name: 'from-chat-model',
prompt: 'ping',
hour: 23,
minute: 1,
timezone: 'Asia/Shanghai',
}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' }, session: { header: { cwd: '/tmp/ws-app' } } } })
assert.equal(created.job.provider, 'minimax-cn')
assert.equal(created.job.model, 'MiniMax-M3')
const explicit = await tools.cron_create.execute({
name: 'pinned',
prompt: 'ping',
hour: 23,
minute: 2,
timezone: 'Asia/Shanghai',
provider: 'deepseek',
model: 'deepseek-chat',
}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' } } })
assert.equal(explicit.job.provider, 'deepseek')
assert.equal(explicit.job.model, 'deepseek-chat')
})
test('resolveCreateCwd prefers explicit cwd then the calling session', () => {
assert.equal(resolveCreateCwd({ cwd: '/tmp/a' }, { agent: { session: { header: { cwd: '/ws' } } } }), '/tmp/a')
assert.equal(callerWorkingDirectory({ agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
assert.equal(resolveCreateCwd({}, { agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
assert.equal(resolveCreateCwd({}, {}), '')
assert.deepEqual(resolveCreateModel({}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' } } }), {
provider: 'minimax-cn',
model: 'MiniMax-M3',
reasoningEffort: '',
})
})
test('resolveCreateModel / normalizeJobModel split doubled provider/model routes', async (t) => {
const { normalizeJobModel, splitProviderModel } = await import('../lib/store.js')
assert.deepEqual(
splitProviderModel('zte/Qwen3-235B-A22B', 'zte/Qwen3-235B-A22B'),
{ provider: 'zte', model: 'Qwen3-235B-A22B' },
)
assert.deepEqual(
splitProviderModel('zte', 'zte/Qwen3-235B-A22B'),
{ provider: 'zte', model: 'Qwen3-235B-A22B' },
)
assert.deepEqual(
splitProviderModel('', 'zte/Qwen3-235B-A22B'),
{ provider: 'zte', model: 'Qwen3-235B-A22B' },
)
// Legitimate model id with slash under a distinct provider stays intact.
assert.deepEqual(
splitProviderModel('openai', 'org/custom-model'),
{ provider: 'openai', model: 'org/custom-model' },
)
assert.deepEqual(
normalizeJobModel({ provider: 'zte/Qwen3-235B-A22B', model: 'zte/Qwen3-235B-A22B' }),
{ provider: 'zte', model: 'Qwen3-235B-A22B', reasoningEffort: '' },
)
assert.deepEqual(
resolveCreateModel(
{ provider: 'zte/Qwen3-235B-A22B', model: 'zte/Qwen3-235B-A22B' },
{ agent: { options: { provider: 'other', model: 'other-model' } } },
),
{ provider: 'zte', model: 'Qwen3-235B-A22B', reasoningEffort: '' },
)
assert.deepEqual(
resolveCreateModel({}, { agent: { options: { provider: 'zte/Qwen3-235B-A22B', model: 'zte/Qwen3-235B-A22B' } } }),
{ provider: 'zte', model: 'Qwen3-235B-A22B', reasoningEffort: '' },
)
})
test('cron_create hour+minute uses today and rejects a guessed past calendar date', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.now(),
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service))
const created = await tools.cron_create.execute({
name: 'tonight',
prompt: 'ping',
hour: 23,
minute: 34,
timezone: 'Asia/Shanghai',
}, {})
assert.equal(created.job.schedule.kind, 'at')
assert.ok(created.job.nextRunAt > Date.now() - 1000)
await assert.rejects(
() => tools.cron_create.execute({
name: 'past',
prompt: 'ping',
at: '2026-05-14T23:34:00+08:00',
timezone: 'Asia/Shanghai',
}, {}),
/already in the past|Current local time/,
)
})
test('cron_create accepts at and overlapping schedule fields prefer after_minutes then hour then at', async (t) => {
const service = await makeService(t)
const tools = byName(cronToolDefinitions(service))
const created = await tools.cron_create.execute({
name: 'once',
prompt: 'ping',
at: '2026-08-24T12:00:00+08:00',
}, {})
assert.equal(created.job.schedule.kind, 'at')
const now = Date.parse('2026-08-24T00:32:00+08:00')
const overlapped = scheduleFromArgs({
at: '00:33',
hour: 0,
minute: 33,
timezone: 'Asia/Shanghai',
}, now)
assert.equal(overlapped.kind, 'at')
assert.equal(new Date(overlapped.at).toISOString(), new Date('2026-08-24T00:33:00+08:00').toISOString())
const exprPlusHour = scheduleFromArgs({
expr: '35 0 24 8 *',
hour: 0,
minute: 35,
timezone: 'Asia/Shanghai',
}, now)
assert.equal(exprPlusHour.kind, 'at')
assert.equal(new Date(exprPlusHour.at).toISOString(), new Date('2026-08-24T00:35:00+08:00').toISOString())
const exprPlusAt = scheduleFromArgs({
expr: '0 9 * * *',
at: '2026-08-24T12:00:00+08:00',
timezone: 'Asia/Shanghai',
}, now)
assert.equal(exprPlusAt.kind, 'at')
assert.equal(exprPlusAt.at, '2026-08-24T12:00:00+08:00')
const delay = scheduleFromArgs({ after_minutes: 1, at: '00:33', hour: 0, timezone: 'Asia/Shanghai' }, now)
assert.equal(delay.kind, 'at')
assert.equal(new Date(delay.at).getTime(), now + 60_000)
})
test('cron_create after_minutes schedules from now even if at and hour are also sent', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.now(),
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service))
const before = Date.now()
const created = await tools.cron_create.execute({
name: 'in-a-minute',
prompt: 'ping',
after_minutes: 1,
at: '00:33',
hour: 0,
minute: 33,
timezone: 'Asia/Shanghai',
}, {})
assert.equal(created.job.schedule.kind, 'at')
assert.ok(created.job.nextRunAt >= before + 50_000)
assert.ok(created.job.nextRunAt <= before + 80_000)
})
test('registerCronGuidance registers the scheduled-tasks skill only', () => {
const skills = []
const off = registerCronGuidance({
skills: {
register(skill) {
skills.push(skill)
return () => {
const at = skills.indexOf(skill)
if (at >= 0) skills.splice(at, 1)
}
},
},
})
assert.equal(skills[0].name, 'scheduled-tasks')
assert.equal(skills[0].source, 'runtime')
assert.match(skills[0].description, /定时任务/)
assert.match(skills[0].content, /hour/)
off()
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('resolveToolIdentity restores super_admin permissions via udsAuth', () => {
const uds = {
getSessionOwner(sessionId) {
return sessionId === 'sess-super' ? '10329667' : null
},
resolveIdentityForEmpNo(empNo) {
if (empNo !== '10329667') return null
return {
empNo: '10329667',
role: 'super_admin',
displayName: 'Super',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
},
getProvisionedWorkspacePath(empNo) {
return `/tmp/deepseek-harness/${empNo}`
},
}
const identity = resolveToolIdentity(
{ agent: { session: { id: 'sess-super', header: { cwd: 'D:/code/gpt' } } } },
{ getUdsAuth: () => uds },
)
assert.equal(identity.empNo, '10329667')
assert.equal(identity.role, 'super_admin')
assert.equal(identity.permissions.canViewAllSessions, true)
})
test('cron_create keeps foreign cwd for super_admin tool identity', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const owners = new Map([['sess-super', '10329667']])
const uds = {
getSessionOwner(id) { return owners.get(id) || null },
resolveIdentityForEmpNo(empNo) {
if (empNo !== '10329667') {
return {
empNo,
role: 'user',
permissions: { canViewAllSessions: false },
workspacePath: `/tmp/user-workspaces/${empNo}`,
}
}
return {
empNo: '10329667',
role: 'super_admin',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
},
getProvisionedWorkspacePath(empNo) {
return empNo === '10329667'
? '/tmp/deepseek-harness/10329667'
: `/tmp/user-workspaces/${empNo}`
},
isUserPath(empNo, candidatePath) {
const root = this.getProvisionedWorkspacePath(empNo)
const cand = String(candidatePath || '').replace(/\\/g, '/')
const normRoot = String(root).replace(/\\/g, '/')
return cand === normRoot || cand.startsWith(`${normRoot}/`)
},
}
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
getUdsAuth: () => uds,
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service))
const exec = {
agent: {
session: {
id: 'sess-super',
header: { cwd: '/tmp/deepseek-harness/10329667' },
},
},
}
const created = await tools.cron_create.execute({
name: 'gpt-cron',
prompt: 'continue BGP',
after_minutes: 1,
timezone: 'Asia/Shanghai',
cwd: 'D:/code/gpt',
}, exec)
assert.equal(created.job.cwd, 'D:/code/gpt')
assert.equal(created.job.ownerEmpNo, '10329667')
})
test('cron_create from IM peer stamps unassigned owner and forces session cwd', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const peer = {
botId: 'bot-a',
conversationKey: 'direct:86138@s.whatsapp.net',
conversationId: '86138@s.whatsapp.net',
kind: 'direct',
phone: '86138',
}
const dshIm = {
resolveSessionPeer: async () => peer,
listTargets: async () => [{
targetId: 'auto-dm-86138',
kind: 'user',
route: { jid: '86138@s.whatsapp.net' },
}],
}
const uds = {
getSessionOwner() { return '10329667' },
resolveIdentityForEmpNo(empNo) {
return {
empNo,
role: 'super_admin',
permissions: { canViewAllSessions: true, canCreateWorkspace: true },
workspacePath: '/tmp/deepseek-harness/10329667',
}
},
getProvisionedWorkspacePath(empNo) {
return `/tmp/deepseek-harness/${empNo}`
},
isUserPath() { return false },
}
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
getUdsAuth: () => uds,
getDshIm: () => dshIm,
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service, { getDshIm: () => dshIm }))
const created = await tools.cron_create.execute({
name: 'im-cron',
prompt: 'channel work',
after_minutes: 2,
timezone: 'Asia/Shanghai',
cwd: 'D:/code/gpt',
}, {
agent: {
session: {
id: 'sess-im',
header: { cwd: '/data/bot-ws/wa' },
},
},
})
assert.equal(created.job.ownerEmpNo, '__unassigned__')
assert.equal(created.job.cwd, '/data/bot-ws/wa')
assert.equal(created.job.origin?.kind, 'im')
assert.equal(created.job.delivery?.kind, 'im')
})
test('agentAccess deny is hidden from tools and blocks pause/delete', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
now: () => Date.now(),
sessionPort: {
async createAndPrompt() {
return { sessionId: 'tool-sess', status: 'succeeded', summary: 'ok' }
},
},
})
const tools = byName(cronToolDefinitions(service))
const created = await tools.cron_create.execute({
name: 'locked',
prompt: 'ping',
after_minutes: 30,
timezone: 'Asia/Shanghai',
}, {})
assert.equal(created.job.agentAccess, undefined)
assert.equal(created.job.permissionPreset, undefined)
assert.equal(created.job.id != null, true)
await service.updateJob(created.job.id, { agentAccess: 'deny', permissionPreset: 'danger-full-access' })
const listed = await tools.cron_list.execute({}, {})
const row = listed.jobs.find((job) => job.id === created.job.id)
assert.ok(row)
assert.equal(row.agentAccess, undefined)
assert.equal(row.permissionPreset, undefined)
await assert.rejects(
() => tools.cron_pause.execute({ id: created.job.id }, {}),
(err) => err.code === 'AGENT_ACCESS_DENIED',
)
await assert.rejects(
() => tools.cron_delete.execute({ id: created.job.id }, {}),
(err) => err.code === 'AGENT_ACCESS_DENIED',
)
const httpView = await service.getJob(created.job.id)
assert.equal(httpView.agentAccess, 'deny')
assert.equal(httpView.permissionPreset, 'danger-full-access')
})
test('normalizePermissionPreset maps UI aliases and rejects junk', async () => {
const { normalizePermissionPreset } = await import('../lib/store.js')
assert.equal(normalizePermissionPreset(''), '')
assert.equal(normalizePermissionPreset({ permissionPreset: 'inherit' }), '')
assert.equal(normalizePermissionPreset('read-only'), 'read-only')
assert.equal(normalizePermissionPreset('workspace-write'), 'workspace-write')
assert.equal(normalizePermissionPreset('full'), 'danger-full-access')
assert.throws(() => normalizePermissionPreset('nope'), (err) => err.code === 'INVALID_PERMISSION_PRESET')
})
test('registerCronTools registers each definition and disposer unregisters', () => {
const registered = []
const ctx = {
tools: {
register(definition) {
registered.push(definition.name)
return () => {
const at = registered.indexOf(definition.name)
if (at >= 0) registered.splice(at, 1)
}
},
},
}
const off = registerCronTools(ctx, {})
assert.deepEqual(registered, [
'cron_create',
'cron_list',
'cron_query',
'cron_runs',
'cron_progress',
'cron_pause',
'cron_resume',
'cron_retrigger',
'cron_delete',
])
off()
assert.deepEqual(registered, [])
})
test('cron tool output schemas never use type arrays (Host rejects them)', () => {
const defs = cronToolDefinitions({
async createJob() { return {} },
async listJobs() { return [] },
async queryJobs() { return { jobs: [], items: [], count: 0 } },
async listRuns() { return { task_id: '', runs: [], next_cursor: null, total: 0 } },
async getProgress() { return { snapshots: [], count: 0 } },
async pauseJob() { return {} },
async resumeJob() { return {} },
async retriggerJob() { return {} },
async deleteJob() { return {} },
})
const bad = []
function walk(node, path) {
if (!node || typeof node !== 'object') return
if (Array.isArray(node.type)) bad.push(`${path}.type=${JSON.stringify(node.type)}`)
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
bad.push(`${path}.additionalProperties must be boolean (got ${typeof node.additionalProperties})`)
}
if (Array.isArray(node.oneOf)) node.oneOf.forEach((branch, i) => walk(branch, `${path}.oneOf[${i}]`))
if (node.properties && typeof node.properties === 'object') {
for (const [key, child] of Object.entries(node.properties)) walk(child, `${path}.properties.${key}`)
}
if (node.items) walk(node.items, `${path}.items`)
}
for (const def of defs) {
walk(def.output?.schema, `${def.name}.output`)
walk(def.parameters, `${def.name}.parameters`)
}
assert.deepEqual(bad, [])
})