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:
oliver 2026-09-06 14:45:37 +08:00
parent 29d946f3dd
commit 67238d746e
7 changed files with 222 additions and 29 deletions

View file

@ -4,7 +4,13 @@
*/
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'
const JOB_SCHEMA = {
@ -107,10 +113,35 @@ export function callerWorkingDirectory(exec) {
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() : ''
// IM peers cannot point scheduled Agents at arbitrary host paths.
if (peer?.botId) return sessionCwd
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) {
@ -165,15 +196,15 @@ export function cronToolDefinitions(service, deps = {}) {
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.' },
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.' },
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.' },
timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' },
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.' },
im_bot_id: { type: 'string', description: 'Opaque botId from IM 投递设置 when delivery=im.' },
im_target_id: { type: 'string', description: 'Opaque targetId from IM 投递设置 when delivery=im.' },
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 (Web/sidebar only; IM chats cannot retarget).' },
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.' },
},
required: ['name', 'prompt'],
@ -203,18 +234,20 @@ export function cronToolDefinitions(service, deps = {}) {
async execute(args, exec) {
aborted(exec)
const timeout = Number(args.timeout_minutes)
const dshIm = getDshIm()
try {
const delivery = await resolveCreateDelivery(args, exec, { dshIm: getDshIm() })
const origin = await resolveCreateOrigin(args, exec, { dshIm: getDshIm() })
const peer = await resolveCallerPeer(exec, dshIm)
const delivery = await resolveCreateDelivery(args, exec, { dshIm })
const origin = await resolveCreateOrigin(args, exec, { dshIm })
const agentPreset = await resolveCreateAgentPreset(args, exec, {
dshIm: getDshIm(),
dshIm,
agentPresets: getAgentPresets(),
})
const job = await service.createJob({
name: args.name,
prompt: args.prompt,
schedule: scheduleFromArgs(args, Date.now()),
cwd: resolveCreateCwd(args, exec),
cwd: resolveCreateCwd(args, exec, { peer }),
...resolveCreateModel(args, exec),
timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
enabled: args.enabled !== false,
@ -263,7 +296,9 @@ export function cronToolDefinitions(service, deps = {}) {
presentCall: () => ({ card: 'generic', title: '列出定时任务' }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
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)
return { jobs, count: jobs.length }
},
@ -284,7 +319,10 @@ export function cronToolDefinitions(service, deps = {}) {
presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }),
async execute(args, 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 }
},
},
@ -304,7 +342,10 @@ export function cronToolDefinitions(service, deps = {}) {
presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }),
async execute(args, 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 }
},
},
@ -332,8 +373,10 @@ export function cronToolDefinitions(service, deps = {}) {
presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }),
async execute(args, exec) {
aborted(exec)
const peer = await resolveCallerPeer(exec, getDshIm())
const id = requireId(args)
try {
await requireOwnedJob(service, id, peer)
await service.deleteJob(id)
return { id, deleted: true }
} catch (error) {