mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-14 00:43:21 +08:00
Isolate IM cron tools and trust-gate read APIs.
Scope list/pause/delete to the calling chat, bind IM delivery to that peer, ignore client job ids, require trusted Host for GETs, and wrap mirrored summaries as system reminders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
29d946f3dd
commit
67238d746e
7 changed files with 222 additions and 29 deletions
|
|
@ -149,9 +149,7 @@ export function matchTargetForPeer(targets, peer) {
|
||||||
const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase()
|
const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase()
|
||||||
if (!jid) continue
|
if (!jid) continue
|
||||||
if (wantGroup && !jid.endsWith('@g.us')) continue
|
if (wantGroup && !jid.endsWith('@g.us')) continue
|
||||||
if (candidates.some((peerJid) => (
|
if (candidates.some((peerJid) => jid === peerJid)) {
|
||||||
jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid)
|
|
||||||
))) {
|
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -365,25 +363,64 @@ export async function resolveCreateDelivery(args = {}, exec = {}, deps = {}) {
|
||||||
const targetId = typeof args.im_target_id === 'string' ? args.im_target_id.trim()
|
const targetId = typeof args.im_target_id === 'string' ? args.im_target_id.trim()
|
||||||
: (typeof args.imTargetId === 'string' ? args.imTargetId.trim() : '')
|
: (typeof args.imTargetId === 'string' ? args.imTargetId.trim() : '')
|
||||||
|
|
||||||
|
const dshIm = deps.dshIm
|
||||||
|
const peer = await resolveCallerPeer(exec, dshIm)
|
||||||
|
|
||||||
|
// IM peers cannot retarget to an arbitrary catalog entry (confused deputy).
|
||||||
|
if (peer?.botId) {
|
||||||
|
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
|
||||||
|
const ensured = await ensureImDeliveryForPeer(peer, dshIm)
|
||||||
|
if (ensured) return ensured
|
||||||
|
if (explicit === 'im' || (botId && targetId)) {
|
||||||
|
const error = new Error('IM sessions cannot pick a free im_bot_id/im_target_id; delivery is bound to this chat')
|
||||||
|
error.code = 'IM_TARGET_FORBIDDEN'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return normalizeDelivery({ kind: 'dsh' })
|
||||||
|
}
|
||||||
|
|
||||||
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
|
if (explicit === 'dsh') return normalizeDelivery({ kind: 'dsh' })
|
||||||
if (explicit === 'im' || (botId && targetId)) {
|
if (explicit === 'im' || (botId && targetId)) {
|
||||||
return normalizeDelivery({ kind: 'im', botId, targetId })
|
return normalizeDelivery({ kind: 'im', botId, targetId })
|
||||||
}
|
}
|
||||||
|
|
||||||
const dshIm = deps.dshIm
|
|
||||||
const sessionId = callerSessionId(exec)
|
|
||||||
if (sessionId && dshIm && typeof dshIm.resolveSessionPeer === 'function') {
|
|
||||||
try {
|
|
||||||
const peer = await dshIm.resolveSessionPeer(sessionId)
|
|
||||||
const ensured = await ensureImDeliveryForPeer(peer, dshIm)
|
|
||||||
if (ensured) return ensured
|
|
||||||
} catch {
|
|
||||||
// fall through to dsh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return normalizeDelivery({ kind: 'dsh' })
|
return normalizeDelivery({ kind: 'dsh' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the IM peer for the tool-calling session, if any.
|
||||||
|
* @param {object} exec
|
||||||
|
* @param {object|undefined} dshIm
|
||||||
|
*/
|
||||||
|
export async function resolveCallerPeer(exec, dshIm) {
|
||||||
|
const sessionId = callerSessionId(exec)
|
||||||
|
if (!sessionId || !dshIm || typeof dshIm.resolveSessionPeer !== 'function') return null
|
||||||
|
try {
|
||||||
|
const peer = await dshIm.resolveSessionPeer(sessionId)
|
||||||
|
return peer?.botId ? peer : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scheduled job was created from this IM conversation.
|
||||||
|
* Web/sidebar callers are unrestricted; IM peers only manage their own jobs.
|
||||||
|
* @param {object|null|undefined} job
|
||||||
|
* @param {object} peer
|
||||||
|
*/
|
||||||
|
export function jobVisibleToPeer(job, peer) {
|
||||||
|
if (!job || !peer?.botId) return false
|
||||||
|
const botId = String(peer.botId).trim()
|
||||||
|
const conversationKey = String(peer.conversationKey || '').trim()
|
||||||
|
const conversationId = String(peer.conversationId || '').trim()
|
||||||
|
const origin = normalizeOrigin(job.origin)
|
||||||
|
if (origin?.kind !== 'im' || origin.peer?.botId !== botId) return false
|
||||||
|
if (conversationKey && origin.peer.conversationKey === conversationKey) return true
|
||||||
|
if (conversationId && origin.peer.conversationId === conversationId) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const IM_MAX_CHARS = 3500
|
const IM_MAX_CHARS = 3500
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -522,14 +559,17 @@ export async function mirrorRunToSession(job, summary, deps = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = formatRunResultBody(job, summary)
|
const text = formatRunResultBody(job, summary)
|
||||||
|
const safe = text.replaceAll('</system-reminder>', '<\\/system-reminder>')
|
||||||
const plugin = typeof deps.pluginName === 'string' && deps.pluginName.trim()
|
const plugin = typeof deps.pluginName === 'string' && deps.pluginName.trim()
|
||||||
? deps.pluginName.trim()
|
? deps.pluginName.trim()
|
||||||
: 'dsh-ops-cron'
|
: 'dsh-ops-cron'
|
||||||
const id = typeof deps.newId === 'function' ? deps.newId() : `cron-mirror-${Date.now()}`
|
const id = typeof deps.newId === 'function' ? deps.newId() : `cron-mirror-${Date.now()}`
|
||||||
|
// Keep role=user + user/message (assistant/message requires model source + open turn).
|
||||||
|
// Wrap as system-reminder so the next model turn does not treat cron output as user intent.
|
||||||
const message = {
|
const message = {
|
||||||
id,
|
id,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [{ type: 'text', text }],
|
content: [{ type: 'text', text: `<system-reminder>\n${safe}\n</system-reminder>` }],
|
||||||
source: { kind: 'plugin', plugin },
|
source: { kind: 'plugin', plugin },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
18
lib/host.js
18
lib/host.js
|
|
@ -178,9 +178,11 @@ export function createHostService(options = {}) {
|
||||||
|
|
||||||
async function createJob(input) {
|
async function createJob(input) {
|
||||||
const t = now()
|
const t = now()
|
||||||
|
// Ignore client-supplied ids on create — otherwise POST/tools can overwrite.
|
||||||
|
const { id: _ignoredId, ...safeInput } = input && typeof input === 'object' ? input : {}
|
||||||
let created
|
let created
|
||||||
await withState((current) => {
|
await withState((current) => {
|
||||||
created = createJobRecord(input, current, t)
|
created = createJobRecord(safeInput, current, t)
|
||||||
return upsertJob(current, created)
|
return upsertJob(current, created)
|
||||||
})
|
})
|
||||||
return jobView(created)
|
return jobView(created)
|
||||||
|
|
@ -389,6 +391,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/settings` && method === 'GET') {
|
if (path === `${API_PREFIX}/settings` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
write(200, { ok: true, settings: state.settings })
|
write(200, { ok: true, settings: state.settings })
|
||||||
return
|
return
|
||||||
|
|
@ -403,6 +406,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/models` && method === 'GET') {
|
if (path === `${API_PREFIX}/models` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const catalog = typeof sessionPort?.listModels === 'function'
|
const catalog = typeof sessionPort?.listModels === 'function'
|
||||||
? await sessionPort.listModels()
|
? await sessionPort.listModels()
|
||||||
: { groups: [], current: null }
|
: { groups: [], current: null }
|
||||||
|
|
@ -411,6 +415,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/presets` && method === 'GET') {
|
if (path === `${API_PREFIX}/presets` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const catalog = typeof sessionPort?.listPresets === 'function'
|
const catalog = typeof sessionPort?.listPresets === 'function'
|
||||||
? await sessionPort.listPresets()
|
? await sessionPort.listPresets()
|
||||||
: { items: [], current: null }
|
: { items: [], current: null }
|
||||||
|
|
@ -419,6 +424,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
|
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
|
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
|
||||||
? await sessionPort.listWorkspaces()
|
? await sessionPort.listWorkspaces()
|
||||||
: []
|
: []
|
||||||
|
|
@ -427,6 +433,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
|
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const dshIm = getDshIm()
|
const dshIm = getDshIm()
|
||||||
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
|
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
|
||||||
write(200, {
|
write(200, {
|
||||||
|
|
@ -456,6 +463,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
|
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
|
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
|
||||||
return
|
return
|
||||||
|
|
@ -474,6 +482,7 @@ export function createHostService(options = {}) {
|
||||||
const jobId = decodeURIComponent(jobMatch[1])
|
const jobId = decodeURIComponent(jobMatch[1])
|
||||||
const rest = jobMatch[2] || ''
|
const rest = jobMatch[2] || ''
|
||||||
if (method === 'GET' && !rest) {
|
if (method === 'GET' && !rest) {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const job = getJob(state, jobId)
|
const job = getJob(state, jobId)
|
||||||
if (!job) return write(404, { ok: false, error: 'job not found' })
|
if (!job) return write(404, { ok: false, error: 'job not found' })
|
||||||
|
|
@ -545,6 +554,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/history` && method === 'GET') {
|
if (path === `${API_PREFIX}/history` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const jobId = url.searchParams.get('jobId') || undefined
|
const jobId = url.searchParams.get('jobId') || undefined
|
||||||
write(200, { ok: true, runs: listHistory(state, jobId).map(runView) })
|
write(200, { ok: true, runs: listHistory(state, jobId).map(runView) })
|
||||||
|
|
@ -552,6 +562,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/preview` && method === 'POST') {
|
if (path === `${API_PREFIX}/preview` && method === 'POST') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const body = await readJsonBody(req)
|
const body = await readJsonBody(req)
|
||||||
const settings = (await snapshot()).settings
|
const settings = (await snapshot()).settings
|
||||||
const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone)
|
const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone)
|
||||||
|
|
@ -561,6 +572,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') {
|
if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') {
|
||||||
|
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const listed = url.searchParams.getAll('id')
|
const listed = url.searchParams.getAll('id')
|
||||||
write(200, {
|
write(200, {
|
||||||
|
|
@ -601,6 +613,10 @@ export function createHostService(options = {}) {
|
||||||
async listJobs() {
|
async listJobs() {
|
||||||
return listJobs(await snapshot()).map(jobView)
|
return listJobs(await snapshot()).map(jobView)
|
||||||
},
|
},
|
||||||
|
async getJob(jobId) {
|
||||||
|
const job = getJob(await snapshot(), jobId)
|
||||||
|
return job ? jobView(job) : null
|
||||||
|
},
|
||||||
async listHistory(jobId) {
|
async listHistory(jobId) {
|
||||||
return listHistory(await snapshot(), jobId).map(runView)
|
return listHistory(await snapshot(), jobId).map(runView)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
2
lib/index.d.ts
vendored
2
lib/index.d.ts
vendored
|
|
@ -46,6 +46,8 @@ export function normalizeOrigin(input?: object): { kind: 'web' | 'im', sessionId
|
||||||
export function resolveCreateDelivery(args?: object, exec?: object, deps?: object): Promise<{ kind: 'dsh' | 'im', botId?: string, targetId?: string }>
|
export function resolveCreateDelivery(args?: object, exec?: object, deps?: object): Promise<{ kind: 'dsh' | 'im', botId?: string, targetId?: string }>
|
||||||
export function resolveCreateOrigin(args?: object, exec?: object, deps?: object): Promise<{ kind: 'web' | 'im', sessionId?: string, peer?: object } | null>
|
export function resolveCreateOrigin(args?: object, exec?: object, deps?: object): Promise<{ kind: 'web' | 'im', sessionId?: string, peer?: object } | null>
|
||||||
export function resolveMirrorSession(job: object, deps?: object): Promise<{ sessionId: string, via: string } | null>
|
export function resolveMirrorSession(job: object, deps?: object): Promise<{ sessionId: string, via: string } | null>
|
||||||
|
export function resolveCallerPeer(exec?: object, dshIm?: object): Promise<object | null>
|
||||||
|
export function jobVisibleToPeer(job: object, peer: object): boolean
|
||||||
export function formatRunResultBody(job: object, summary?: string): string
|
export function formatRunResultBody(job: object, summary?: string): string
|
||||||
export function deliverRunToIm(job: object, summary?: string, deps?: object): Promise<object>
|
export function deliverRunToIm(job: object, summary?: string, deps?: object): Promise<object>
|
||||||
export function mirrorRunToSession(job: object, summary?: string, deps?: object): Promise<object>
|
export function mirrorRunToSession(job: object, summary?: string, deps?: object): Promise<object>
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,11 @@ export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX
|
||||||
export {
|
export {
|
||||||
deliverRunToIm,
|
deliverRunToIm,
|
||||||
formatRunResultBody,
|
formatRunResultBody,
|
||||||
|
jobVisibleToPeer,
|
||||||
mirrorRunToSession,
|
mirrorRunToSession,
|
||||||
normalizeDelivery,
|
normalizeDelivery,
|
||||||
normalizeOrigin,
|
normalizeOrigin,
|
||||||
|
resolveCallerPeer,
|
||||||
resolveCreateDelivery,
|
resolveCreateDelivery,
|
||||||
resolveCreateOrigin,
|
resolveCreateOrigin,
|
||||||
resolveMirrorSession,
|
resolveMirrorSession,
|
||||||
|
|
|
||||||
69
lib/tools.js
69
lib/tools.js
|
|
@ -4,7 +4,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatInZone, resolveTodayAt } from './scheduler.js'
|
import { formatInZone, resolveTodayAt } from './scheduler.js'
|
||||||
import { deliveryLine, resolveCreateDelivery, resolveCreateOrigin } from './delivery.js'
|
import {
|
||||||
|
deliveryLine,
|
||||||
|
jobVisibleToPeer,
|
||||||
|
resolveCallerPeer,
|
||||||
|
resolveCreateDelivery,
|
||||||
|
resolveCreateOrigin,
|
||||||
|
} from './delivery.js'
|
||||||
import { resolveCreateAgentPreset } from './preset.js'
|
import { resolveCreateAgentPreset } from './preset.js'
|
||||||
|
|
||||||
const JOB_SCHEMA = {
|
const JOB_SCHEMA = {
|
||||||
|
|
@ -107,10 +113,35 @@ export function callerWorkingDirectory(exec) {
|
||||||
return String(session?.header?.cwd || session?.cwd || exec?.agent?.cwd || '').trim()
|
return String(session?.header?.cwd || session?.cwd || exec?.agent?.cwd || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCreateCwd(args, exec) {
|
export function resolveCreateCwd(args, exec, { peer = null } = {}) {
|
||||||
|
const sessionCwd = callerWorkingDirectory(exec)
|
||||||
const passed = typeof args?.cwd === 'string' ? args.cwd.trim() : ''
|
const passed = typeof args?.cwd === 'string' ? args.cwd.trim() : ''
|
||||||
|
// IM peers cannot point scheduled Agents at arbitrary host paths.
|
||||||
|
if (peer?.botId) return sessionCwd
|
||||||
if (passed) return passed
|
if (passed) return passed
|
||||||
return callerWorkingDirectory(exec)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwnedJob(service, id, peer) {
|
||||||
|
const job = await service.getJob?.(id)
|
||||||
|
if (job) return forbidForeignJob(job, peer)
|
||||||
|
// Fallback when getJob is absent: list and find.
|
||||||
|
const jobs = await service.listJobs()
|
||||||
|
const hit = (jobs || []).find((row) => row.id === id)
|
||||||
|
if (!hit) {
|
||||||
|
const error = new Error('job not found')
|
||||||
|
error.code = 'NOT_FOUND'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return forbidForeignJob(hit, peer)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function callerModelSelection(exec) {
|
export function callerModelSelection(exec) {
|
||||||
|
|
@ -165,15 +196,15 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
minute: { type: 'integer', description: '0-59, used with hour.' },
|
minute: { type: 'integer', description: '0-59, used with hour.' },
|
||||||
timezone: { type: 'string', description: 'IANA timezone for expr/at. Default Asia/Shanghai. Do not pass UTC unless the user asked for UTC.' },
|
timezone: { type: 'string', description: 'IANA timezone for expr/at. Default Asia/Shanghai. Do not pass UTC unless the user asked for UTC.' },
|
||||||
time_zone: { type: 'string', description: 'Alias of timezone.' },
|
time_zone: { type: 'string', description: 'Alias of timezone.' },
|
||||||
cwd: { type: 'string', description: 'Filesystem path of the workspace this job should run in. When the user is in a workspace conversation, pass THAT workspace path (current session cwd). If omitted, the current session cwd is used. Only skip this to isolate from the project if the user asked for a custom folder.' },
|
cwd: { type: 'string', description: 'Filesystem path of the workspace this job should run in. When the user is in a workspace conversation, pass THAT workspace path (current session cwd). If omitted, the current session cwd is used. WhatsApp/IM creates always use the current session cwd.' },
|
||||||
provider: { type: 'string', description: 'Provider route for this job (e.g. minimax-cn, deepseek). Must be passed with model. If omitted, the current session model is stored so quota stays predictable.' },
|
provider: { type: 'string', description: 'Provider route for this job (e.g. minimax-cn, deepseek). Must be passed with model. If omitted, the current session model is stored so quota stays predictable.' },
|
||||||
model: { type: 'string', description: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' },
|
model: { type: 'string', description: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' },
|
||||||
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
|
reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' },
|
||||||
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
|
||||||
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
|
enabled: { type: 'boolean', description: 'If false, create paused. Default true.' },
|
||||||
delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session.' },
|
delivery: { type: 'string', description: 'dsh (sidebar session) or im (proactive WhatsApp/IM via botId+targetId). Omit to auto-detect from the current session. On WhatsApp/IM, delivery is always bound to the current chat — free im_bot_id/im_target_id are ignored.' },
|
||||||
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im.' },
|
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im (Web/sidebar only; IM chats cannot retarget).' },
|
||||||
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im.' },
|
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im (Web/sidebar only; IM chats cannot retarget).' },
|
||||||
agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' },
|
agent_preset: { type: 'string', description: 'Agent preset id for scheduled runs. Omit to inherit from the current WhatsApp/IM chat or Host default.' },
|
||||||
},
|
},
|
||||||
required: ['name', 'prompt'],
|
required: ['name', 'prompt'],
|
||||||
|
|
@ -203,18 +234,20 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const timeout = Number(args.timeout_minutes)
|
const timeout = Number(args.timeout_minutes)
|
||||||
|
const dshIm = getDshIm()
|
||||||
try {
|
try {
|
||||||
const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() })
|
const peer = await resolveCallerPeer(exec, dshIm)
|
||||||
const origin = await resolveCreateOrigin(args, exec, { dshIm: getDshIm() })
|
const delivery = await resolveCreateDelivery(args, exec, { dshIm })
|
||||||
|
const origin = await resolveCreateOrigin(args, exec, { dshIm })
|
||||||
const agentPreset = await resolveCreateAgentPreset(args, exec, {
|
const agentPreset = await resolveCreateAgentPreset(args, exec, {
|
||||||
dshIm: getDshIm(),
|
dshIm,
|
||||||
agentPresets: getAgentPresets(),
|
agentPresets: getAgentPresets(),
|
||||||
})
|
})
|
||||||
const job = await service.createJob({
|
const job = await service.createJob({
|
||||||
name: args.name,
|
name: args.name,
|
||||||
prompt: args.prompt,
|
prompt: args.prompt,
|
||||||
schedule: scheduleFromArgs(args, Date.now()),
|
schedule: scheduleFromArgs(args, Date.now()),
|
||||||
cwd: resolveCreateCwd(args, exec),
|
cwd: resolveCreateCwd(args, exec, { peer }),
|
||||||
...resolveCreateModel(args, exec),
|
...resolveCreateModel(args, exec),
|
||||||
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
||||||
enabled: args.enabled !== false,
|
enabled: args.enabled !== false,
|
||||||
|
|
@ -263,7 +296,9 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
|
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
|
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||||
let jobs = await service.listJobs()
|
let jobs = await service.listJobs()
|
||||||
|
if (peer?.botId) jobs = jobs.filter((job) => jobVisibleToPeer(job, peer))
|
||||||
if (args?.enabled_only === true) jobs = jobs.filter((job) => job.enabled !== false)
|
if (args?.enabled_only === true) jobs = jobs.filter((job) => job.enabled !== false)
|
||||||
return { jobs, count: jobs.length }
|
return { jobs, count: jobs.length }
|
||||||
},
|
},
|
||||||
|
|
@ -284,7 +319,10 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const job = await service.pauseJob(requireId(args), false)
|
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||||
|
const id = requireId(args)
|
||||||
|
await requireOwnedJob(service, id, peer)
|
||||||
|
const job = await service.pauseJob(id, false)
|
||||||
return { job }
|
return { job }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -304,7 +342,10 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
const job = await service.pauseJob(requireId(args), true)
|
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||||
|
const id = requireId(args)
|
||||||
|
await requireOwnedJob(service, id, peer)
|
||||||
|
const job = await service.pauseJob(id, true)
|
||||||
return { job }
|
return { job }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -332,8 +373,10 @@ export function cronToolDefinitions(service, deps = {}) {
|
||||||
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
|
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
aborted(exec)
|
aborted(exec)
|
||||||
|
const peer = await resolveCallerPeer(exec, getDshIm())
|
||||||
const id = requireId(args)
|
const id = requireId(args)
|
||||||
try {
|
try {
|
||||||
|
await requireOwnedJob(service, id, peer)
|
||||||
await service.deleteJob(id)
|
await service.deleteJob(id)
|
||||||
return { id, deleted: true }
|
return { id, deleted: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
formatRunResultBody,
|
formatRunResultBody,
|
||||||
deliverRunToIm,
|
deliverRunToIm,
|
||||||
mirrorRunToSession,
|
mirrorRunToSession,
|
||||||
|
jobVisibleToPeer,
|
||||||
} from '../lib/delivery.js'
|
} from '../lib/delivery.js'
|
||||||
|
|
||||||
test('normalizeDelivery defaults to dsh', () => {
|
test('normalizeDelivery defaults to dsh', () => {
|
||||||
|
|
@ -307,10 +308,75 @@ test('mirrorRunToSession appends plugin notice without waking a turn', async ()
|
||||||
assert.equal(appended[0][0], 'user/message')
|
assert.equal(appended[0][0], 'user/message')
|
||||||
assert.match(appended[0][1].content[0].text, /日报/)
|
assert.match(appended[0][1].content[0].text, /日报/)
|
||||||
assert.match(appended[0][1].content[0].text, /line one/)
|
assert.match(appended[0][1].content[0].text, /line one/)
|
||||||
|
assert.match(appended[0][1].content[0].text, /<system-reminder>/)
|
||||||
assert.equal(appended[0][1].source.kind, 'plugin')
|
assert.equal(appended[0][1].source.kind, 'plugin')
|
||||||
assert.deepEqual(appended[0][2], { surfaceOp: 'append' })
|
assert.deepEqual(appended[0][2], { surfaceOp: 'append' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('matchTargetForPeer requires exact JID equality', () => {
|
||||||
|
const targets = [{
|
||||||
|
targetId: 'suffix-trap',
|
||||||
|
kind: 'user',
|
||||||
|
route: { jid: '8613800138000@s.whatsapp.net' },
|
||||||
|
}]
|
||||||
|
const peer = {
|
||||||
|
kind: 'direct',
|
||||||
|
conversationId: '13800138000@s.whatsapp.net',
|
||||||
|
phone: '13800138000',
|
||||||
|
}
|
||||||
|
assert.equal(matchTargetForPeer(targets, peer), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('jobVisibleToPeer scopes IM ownership to origin conversation', () => {
|
||||||
|
const peer = {
|
||||||
|
botId: 'bot-a',
|
||||||
|
conversationKey: 'group:120363@g.us:user:1@lid',
|
||||||
|
conversationId: '120363@g.us',
|
||||||
|
}
|
||||||
|
assert.equal(jobVisibleToPeer({
|
||||||
|
origin: {
|
||||||
|
kind: 'im',
|
||||||
|
peer: { botId: 'bot-a', conversationKey: 'group:120363@g.us:user:1@lid', conversationId: '120363@g.us' },
|
||||||
|
},
|
||||||
|
}, peer), true)
|
||||||
|
assert.equal(jobVisibleToPeer({
|
||||||
|
origin: {
|
||||||
|
kind: 'im',
|
||||||
|
peer: { botId: 'bot-a', conversationKey: 'group:other@g.us', conversationId: 'other@g.us' },
|
||||||
|
},
|
||||||
|
}, peer), false)
|
||||||
|
assert.equal(jobVisibleToPeer({
|
||||||
|
origin: { kind: 'web', sessionId: 'web-1' },
|
||||||
|
}, peer), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveCreateDelivery binds IM peers to the current chat', async () => {
|
||||||
|
const peer = {
|
||||||
|
botId: 'bot-a',
|
||||||
|
conversationKey: 'direct:86138@s.whatsapp.net',
|
||||||
|
conversationId: '86138@s.whatsapp.net',
|
||||||
|
kind: 'direct',
|
||||||
|
phone: '86138',
|
||||||
|
}
|
||||||
|
const delivery = await resolveCreateDelivery(
|
||||||
|
{ delivery: 'im', im_bot_id: 'other-bot', im_target_id: 'other-target' },
|
||||||
|
{ agent: { session: { id: 'sess-im' } } },
|
||||||
|
{
|
||||||
|
dshIm: {
|
||||||
|
resolveSessionPeer: async () => peer,
|
||||||
|
listTargets: async () => [{
|
||||||
|
targetId: 'auto-dm',
|
||||||
|
kind: 'user',
|
||||||
|
route: { jid: '86138@s.whatsapp.net' },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert.equal(delivery.kind, 'im')
|
||||||
|
assert.equal(delivery.botId, 'bot-a')
|
||||||
|
assert.equal(delivery.targetId, 'auto-dm')
|
||||||
|
})
|
||||||
|
|
||||||
test('mirrorRunToSession resumes a cold origin session instead of create', async () => {
|
test('mirrorRunToSession resumes a cold origin session instead of create', async () => {
|
||||||
const appended = []
|
const appended = []
|
||||||
const resumed = []
|
const resumed = []
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,30 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
||||||
assert.deepEqual(visible.body.visible, ['other'])
|
assert.deepEqual(visible.body.visible, ['other'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('createJob ignores client-supplied id to prevent overwrite', async (t) => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
const service = createHostService({
|
||||||
|
filePath: join(dir, 'store.json'),
|
||||||
|
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||||
|
})
|
||||||
|
const first = await service.createJob({
|
||||||
|
name: 'keep-me',
|
||||||
|
prompt: 'first',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
|
||||||
|
})
|
||||||
|
const second = await service.createJob({
|
||||||
|
id: first.id,
|
||||||
|
name: 'attacker',
|
||||||
|
prompt: 'overwrite?',
|
||||||
|
schedule: { kind: 'cron', expr: '0 10 * * *', timezone: 'Asia/Shanghai' },
|
||||||
|
})
|
||||||
|
assert.notEqual(second.id, first.id)
|
||||||
|
const listed = await service.listJobs()
|
||||||
|
assert.equal(listed.find((job) => job.id === first.id)?.name, 'keep-me')
|
||||||
|
assert.equal(listed.find((job) => job.id === second.id)?.name, 'attacker')
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /runs/:id/open reveals the run session id', async (t) => {
|
test('POST /runs/:id/open reveals the run session id', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue