From ad6e90f1978332183b758093e876e8f08678f777 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 21:54:31 +0800 Subject: [PATCH] Add sidebar-only agentAccess lock for scheduled jobs. Default remains allow; humans can deny agent pause/resume/retrigger/delete in the panel while tools never expose or set the field. Co-authored-by: Cursor --- lib/client.js | 21 +++++++++++++++++++ lib/fire.js | 1 + lib/host.js | 3 +++ lib/index.js | 2 +- lib/store.js | 22 ++++++++++++++++++++ lib/tools.js | 51 +++++++++++++++++++++++++++++++++++++--------- test/tools.test.js | 41 +++++++++++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 11 deletions(-) diff --git a/lib/client.js b/lib/client.js index fdd9a97..1b42ca7 100644 --- a/lib/client.js +++ b/lib/client.js @@ -430,6 +430,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标(仅超管)。普通用户请在 IM 聊天里用 cron_create 建渠道任务。目标在 IM 机器人 → 投递设置里创建。', mirrorToSession: '镜像回原会话', mirrorToSessionHint: '开启后把每次运行摘要写入创建时的 WhatsApp/Web 会话(不新开模型轮次)。默认关闭。', + agentAccess: 'Agent 操作', + agentAccessAllow: '允许 Agent 用工具管理(默认)', + agentAccessDeny: '仅人工(禁止 Agent 暂停/恢复/重触发/删除)', + agentAccessHint: '仅侧栏可改;Agent 看不到此开关。默认保持现状(允许)。关掉后 Agent 仍可查询进度,但无法改任务。', loginRequired: '登录后才能使用定时任务', runNoSession: '这次运行没有可打开的会话', runOpenFailed: '打不开这次对话,会话可能已被删除', @@ -498,6 +502,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba deliveryHint: 'Pick a saved IM delivery target (super_admin only). Regular users should create channel jobs from the IM chat via cron_create. Create targets under IM bot → Delivery settings.', mirrorToSession: 'Mirror into origin session', mirrorToSessionHint: 'When enabled, append each run summary into the creating WhatsApp/Web session (no new model turn). Off by default.', + agentAccess: 'Agent control', + agentAccessAllow: 'Allow agents to manage via tools (default)', + agentAccessDeny: 'Human only (block agent pause/resume/retrigger/delete)', + agentAccessHint: 'Sidebar only; agents never see this toggle. Default keeps current behavior (allow). When denied, agents can still query progress but cannot mutate the job.', loginRequired: 'Sign in to use scheduled tasks', runNoSession: 'This run has no session to open', runOpenFailed: 'Could not open this chat; the session may have been deleted', @@ -755,6 +763,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba imBotId: '', imTargetId: '', mirrorToSession: false, + agentAccess: 'allow', labelsText: '', persistKind: 'retain', archiveEndpoint: '', @@ -781,6 +790,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba imBotId: job.delivery?.botId || '', imTargetId: job.delivery?.targetId || '', mirrorToSession: job.mirrorToSession === true, + agentAccess: job.agentAccess === 'deny' ? 'deny' : 'allow', labelsText: formatLabelsText(job.labels), persistKind: persistHistoryKind(job), archiveEndpoint: job.persistHistory?.kind === 'archive' ? (job.persistHistory.endpoint || '') : '', @@ -1421,6 +1431,16 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba ? h(ImDeliveryFields, { t, form, setForm, imCatalog }) : null, h('span', { className: 'dsh-ct-cwdHint' }, t('deliveryHint')), + h('label', null, t('agentAccess'), + h('select', { + value: form.agentAccess === 'deny' ? 'deny' : 'allow', + onChange: (e) => setForm({ ...form, agentAccess: e.target.value }), + }, + h('option', { value: 'allow' }, t('agentAccessAllow')), + h('option', { value: 'deny' }, t('agentAccessDeny')), + ), + ), + h('span', { className: 'dsh-ct-cwdHint' }, t('agentAccessHint')), h('label', { className: 'dsh-ct-check' }, h('input', { type: 'checkbox', @@ -1689,6 +1709,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba ? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId } : { kind: 'dsh' }, mirrorToSession: form.mirrorToSession === true, + agentAccess: form.agentAccess === 'deny' ? 'deny' : 'allow', labels: parseLabelsText(form.labelsText), persistHistory: form.persistKind === 'forever' ? { kind: 'forever' } diff --git a/lib/fire.js b/lib/fire.js index 7a1e751..846a8d2 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -108,6 +108,7 @@ export function publicJob(job, runs = null) { model: job.model || '', reasoningEffort: job.reasoningEffort || '', agentPreset: job.agentPreset || '', + agentAccess: job.agentAccess === 'deny' ? 'deny' : 'allow', ownerEmpNo: job.ownerEmpNo || '', ownerDisplayName: job.ownerDisplayName || '', delivery: job.delivery && job.delivery.kind === 'im' diff --git a/lib/host.js b/lib/host.js index c627106..999e6bb 100644 --- a/lib/host.js +++ b/lib/host.js @@ -630,6 +630,9 @@ export function createHostService(options = {}) { model: patch.model !== undefined ? patch.model : job.model, reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort, agentPreset: patch.agentPreset !== undefined ? patch.agentPreset : job.agentPreset, + agentAccess: patch.agentAccess !== undefined + ? patch.agentAccess + : (patch.agent_access !== undefined ? patch.agent_access : job.agentAccess), delivery: patch.delivery !== undefined ? mergeDeliveryMention(job.delivery, patch.delivery) : job.delivery, diff --git a/lib/index.js b/lib/index.js index 96f32f8..66e8314 100644 --- a/lib/index.js +++ b/lib/index.js @@ -48,7 +48,7 @@ export { workspaceVisibleIds, } from './isolation.js' export { wrapScheduledPrompt } from './prompt.js' -export { normalizeJobModel, splitProviderModel } from './store.js' +export { normalizeJobModel, normalizeAgentAccess, agentMayMutateJob, splitProviderModel } from './store.js' export { assertCanAccessJob, canViewAllJobs, diff --git a/lib/store.js b/lib/store.js index 2f485ad..a332c12 100644 --- a/lib/store.js +++ b/lib/store.js @@ -77,6 +77,26 @@ export function newId() { return randomUUID() } +/** + * Whether agents may mutate this job via cron_* tools. + * Default allow (= current behavior). Only the sidebar should set deny. + * @param {unknown} input job or raw value + * @returns {'allow'|'deny'} + */ +export function normalizeAgentAccess(input) { + const raw = input && typeof input === 'object' && !Array.isArray(input) + ? (input.agentAccess ?? input.agent_access) + : input + if (raw === false || raw === 0 || raw === 'deny' || raw === 'human' || raw === 'locked' || raw === 'off') { + return 'deny' + } + return 'allow' +} + +export function agentMayMutateJob(job) { + return normalizeAgentAccess(job) === 'allow' +} + /** * Repair provider/model pairs when LLMs or hosts pass a combined "provider/model" * route in one or both fields (e.g. provider=model="zte/Qwen3-…" → zte + Qwen3-…). @@ -169,6 +189,7 @@ export function createJobRecord(input, state, now) { input.persistHistory ?? input.persist_history, settings.historyLimit, ) + const agentAccess = normalizeAgentAccess(input) const job = { id: String(input.id || newId()), name, @@ -182,6 +203,7 @@ export function createJobRecord(input, state, now) { model: model.model, reasoningEffort: model.reasoningEffort, agentPreset, + agentAccess, delivery, mirrorToSession, ownerEmpNo, diff --git a/lib/tools.js b/lib/tools.js index 2f1fe32..22bf5a9 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -20,7 +20,7 @@ import { jobVisibleToIdentity, UNASSIGNED_OWNER, } from './ownership.js' -import { splitProviderModel } from './store.js' +import { agentMayMutateJob, splitProviderModel } from './store.js' const JOB_SCHEMA = { type: 'object', @@ -216,6 +216,20 @@ function jobLine(job) { return `${job.name} [${life}${stuck}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} ${delivery}${labels} next=${when} id=${job.id}` } +/** Strip human-only fields so agents cannot see or game panel-only controls. */ +function forAgentView(job) { + if (!job || typeof job !== 'object') return job + const { agentAccess: _hidden, ...rest } = job + return rest +} + +function assertAgentMayMutate(job) { + if (agentMayMutateJob(job)) return + const error = new Error('this job is human-managed; change “Agent 操作” in the 定时任务 panel (agents cannot toggle it)') + error.code = 'AGENT_ACCESS_DENIED' + throw error +} + export function callerWorkingDirectory(exec) { const session = exec?.agent?.session return String(session?.header?.cwd || session?.cwd || exec?.agent?.cwd || '').trim() @@ -458,7 +472,7 @@ export function cronToolDefinitions(service, deps = {}) { agentPreset, ...monitorFieldsFromArgs(args), }, identity, { fromImPeer: !!peer?.botId }) - return { job } + return { job: forAgentView(job) } } catch (error) { const tz = args.timezone || args.time_zone || 'Asia/Shanghai' const nowText = formatInZone(Date.now(), tz) @@ -512,7 +526,8 @@ export function cronToolDefinitions(service, deps = {}) { let jobs = await service.listJobs(identity, Object.keys(query).length ? query : null) 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 } + const visible = jobs.map(forAgentView) + return { jobs: visible, count: visible.length } }, }, { @@ -565,6 +580,12 @@ export function cronToolDefinitions(service, deps = {}) { result.items = (result.items || []).filter((item) => jobVisibleToPeer(item.job, peer)) result.count = result.jobs.length } + result.jobs = (result.jobs || []).map(forAgentView) + result.items = (result.items || []).map((item) => ( + item && typeof item === 'object' + ? { ...item, job: forAgentView(item.job) } + : item + )) return result }, }, @@ -665,6 +686,11 @@ export function cronToolDefinitions(service, deps = {}) { result.snapshots = (result.snapshots || []).filter((row) => jobVisibleToPeer(row.job, peer)) result.count = result.snapshots.length } + result.snapshots = (result.snapshots || []).map((row) => ( + row && typeof row === 'object' + ? { ...row, job: forAgentView(row.job) } + : row + )) return result }, }, @@ -687,9 +713,10 @@ export function cronToolDefinitions(service, deps = {}) { const peer = await resolveCallerPeer(exec, getDshIm()) const identity = resolveToolIdentity(exec, service) const id = requireId(args) - await requireOwnedJob(service, id, peer, identity) + const owned = await requireOwnedJob(service, id, peer, identity) + assertAgentMayMutate(owned) const job = await service.pauseJob(id, false, identity) - return { job } + return { job: forAgentView(job) } }, }, { @@ -711,9 +738,10 @@ export function cronToolDefinitions(service, deps = {}) { const peer = await resolveCallerPeer(exec, getDshIm()) const identity = resolveToolIdentity(exec, service) const id = requireId(args) - await requireOwnedJob(service, id, peer, identity) + const owned = await requireOwnedJob(service, id, peer, identity) + assertAgentMayMutate(owned) const job = await service.pauseJob(id, true, identity) - return { job } + return { job: forAgentView(job) } }, }, { @@ -760,10 +788,12 @@ export function cronToolDefinitions(service, deps = {}) { const identity = resolveToolIdentity(exec, service) const id = String(args?.task_id || args?.id || '').trim() if (!id) throw new Error('task_id is required') - await requireOwnedJob(service, id, peer, identity) - return service.retriggerJob(id, { + const owned = await requireOwnedJob(service, id, peer, identity) + assertAgentMayMutate(owned) + const result = await service.retriggerJob(id, { after_minutes: args?.after_minutes, }, identity) + return { ...result, job: forAgentView(result.job) } }, }, { @@ -794,7 +824,8 @@ export function cronToolDefinitions(service, deps = {}) { const identity = resolveToolIdentity(exec, service) const id = requireId(args) try { - await requireOwnedJob(service, id, peer, identity) + const owned = await requireOwnedJob(service, id, peer, identity) + assertAgentMayMutate(owned) await service.deleteJob(id) return { id, deleted: true } } catch (error) { diff --git a/test/tools.test.js b/test/tools.test.js index c7c24b4..44e4daf 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -453,6 +453,47 @@ test('cron_create from IM peer stamps unassigned owner and forces session cwd', 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.id != null, true) + + await service.updateJob(created.job.id, { agentAccess: 'deny' }) + 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) + + 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') +}) + test('registerCronTools registers each definition and disposer unregisters', () => { const registered = [] const ctx = {