diff --git a/lib/delivery.js b/lib/delivery.js
index 5caff5c..cd85de2 100644
--- a/lib/delivery.js
+++ b/lib/delivery.js
@@ -149,9 +149,7 @@ export function matchTargetForPeer(targets, peer) {
const jid = String(route.jid || route.chatId || route.openId || route.userId || '').trim().toLowerCase()
if (!jid) continue
if (wantGroup && !jid.endsWith('@g.us')) continue
- if (candidates.some((peerJid) => (
- jid === peerJid || peerJid.endsWith(jid) || jid.endsWith(peerJid)
- ))) {
+ if (candidates.some((peerJid) => jid === peerJid)) {
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()
: (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 === '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' })
}
+/**
+ * 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
/**
@@ -522,14 +559,17 @@ export async function mirrorRunToSession(job, summary, deps = {}) {
}
const text = formatRunResultBody(job, summary)
+ const safe = text.replaceAll('', '<\\/system-reminder>')
const plugin = typeof deps.pluginName === 'string' && deps.pluginName.trim()
? deps.pluginName.trim()
: 'dsh-ops-cron'
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 = {
id,
role: 'user',
- content: [{ type: 'text', text }],
+ content: [{ type: 'text', text: `\n${safe}\n` }],
source: { kind: 'plugin', plugin },
}
diff --git a/lib/host.js b/lib/host.js
index a1c1412..0eeb026 100644
--- a/lib/host.js
+++ b/lib/host.js
@@ -178,9 +178,11 @@ export function createHostService(options = {}) {
async function createJob(input) {
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
await withState((current) => {
- created = createJobRecord(input, current, t)
+ created = createJobRecord(safeInput, current, t)
return upsertJob(current, created)
})
return jobView(created)
@@ -389,6 +391,7 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/settings` && method === 'GET') {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const state = await snapshot()
write(200, { ok: true, settings: state.settings })
return
@@ -403,6 +406,7 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/models` && method === 'GET') {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const catalog = typeof sessionPort?.listModels === 'function'
? await sessionPort.listModels()
: { groups: [], current: null }
@@ -411,6 +415,7 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/presets` && method === 'GET') {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const catalog = typeof sessionPort?.listPresets === 'function'
? await sessionPort.listPresets()
: { items: [], current: null }
@@ -419,6 +424,7 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
? await sessionPort.listWorkspaces()
: []
@@ -427,6 +433,7 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const dshIm = getDshIm()
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
write(200, {
@@ -456,6 +463,7 @@ export function createHostService(options = {}) {
}
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const state = await snapshot()
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
return
@@ -474,6 +482,7 @@ export function createHostService(options = {}) {
const jobId = decodeURIComponent(jobMatch[1])
const rest = jobMatch[2] || ''
if (method === 'GET' && !rest) {
+ if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const state = await snapshot()
const job = getJob(state, jobId)
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 (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const state = await snapshot()
const jobId = url.searchParams.get('jobId') || undefined
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 (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const body = await readJsonBody(req)
const settings = (await snapshot()).settings
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 (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
const state = await snapshot()
const listed = url.searchParams.getAll('id')
write(200, {
@@ -601,6 +613,10 @@ export function createHostService(options = {}) {
async listJobs() {
return listJobs(await snapshot()).map(jobView)
},
+ async getJob(jobId) {
+ const job = getJob(await snapshot(), jobId)
+ return job ? jobView(job) : null
+ },
async listHistory(jobId) {
return listHistory(await snapshot(), jobId).map(runView)
},
diff --git a/lib/index.d.ts b/lib/index.d.ts
index 6794c52..3c3abfb 100644
--- a/lib/index.d.ts
+++ b/lib/index.d.ts
@@ -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 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 resolveCallerPeer(exec?: object, dshIm?: object): Promise