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>
This commit is contained in:
oliver 2026-09-10 22:08:39 +08:00
parent ad6e90f197
commit 8cf624ce7f
8 changed files with 140 additions and 4 deletions

View file

@ -434,6 +434,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
agentAccessAllow: '允许 Agent 用工具管理(默认)',
agentAccessDeny: '仅人工(禁止 Agent 暂停/恢复/重触发/删除)',
agentAccessHint: '仅侧栏可改;Agent 看不到此开关。默认保持现状(允许)。关掉后 Agent 仍可查询进度,但无法改任务。',
permissionPreset: '操作权限',
permissionPresetDefault: '跟随 Host 默认(当前行为)',
permissionPresetReadOnly: '仅可查看',
permissionPresetWorkspaceWrite: '可写入工作区',
permissionPresetFullAccess: '完全权限',
permissionPresetHint: '仅侧栏可改。默认不指定,触发时沿用 Host「新会话」默认权限;可手工提权到可写/完全权限。Agent 不可见也不可改。',
loginRequired: '登录后才能使用定时任务',
runNoSession: '这次运行没有可打开的会话',
runOpenFailed: '打不开这次对话,会话可能已被删除',
@ -506,6 +512,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
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.',
permissionPreset: 'Run permission',
permissionPresetDefault: 'Host default (current behavior)',
permissionPresetReadOnly: 'Read only',
permissionPresetWorkspaceWrite: 'Workspace write',
permissionPresetFullAccess: 'Full access',
permissionPresetHint: 'Sidebar only. Leave default to inherit the Host new-session permission preset; raise manually to workspace write or full access. Agents cannot see or change this.',
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',
@ -764,6 +776,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
imTargetId: '',
mirrorToSession: false,
agentAccess: 'allow',
permissionPreset: '',
labelsText: '',
persistKind: 'retain',
archiveEndpoint: '',
@ -791,6 +804,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
imTargetId: job.delivery?.targetId || '',
mirrorToSession: job.mirrorToSession === true,
agentAccess: job.agentAccess === 'deny' ? 'deny' : 'allow',
permissionPreset: job.permissionPreset || '',
labelsText: formatLabelsText(job.labels),
persistKind: persistHistoryKind(job),
archiveEndpoint: job.persistHistory?.kind === 'archive' ? (job.persistHistory.endpoint || '') : '',
@ -1441,6 +1455,18 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('agentAccessHint')),
h('label', null, t('permissionPreset'),
h('select', {
value: form.permissionPreset || '',
onChange: (e) => setForm({ ...form, permissionPreset: e.target.value }),
},
h('option', { value: '' }, t('permissionPresetDefault')),
h('option', { value: 'read-only' }, t('permissionPresetReadOnly')),
h('option', { value: 'workspace-write' }, t('permissionPresetWorkspaceWrite')),
h('option', { value: 'danger-full-access' }, t('permissionPresetFullAccess')),
),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('permissionPresetHint')),
h('label', { className: 'dsh-ct-check' },
h('input', {
type: 'checkbox',
@ -1710,6 +1736,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
: { kind: 'dsh' },
mirrorToSession: form.mirrorToSession === true,
agentAccess: form.agentAccess === 'deny' ? 'deny' : 'allow',
permissionPreset: form.permissionPreset || '',
labels: parseLabelsText(form.labelsText),
persistHistory: form.persistKind === 'forever'
? { kind: 'forever' }

View file

@ -109,6 +109,7 @@ export function publicJob(job, runs = null) {
reasoningEffort: job.reasoningEffort || '',
agentPreset: job.agentPreset || '',
agentAccess: job.agentAccess === 'deny' ? 'deny' : 'allow',
permissionPreset: job.permissionPreset || '',
ownerEmpNo: job.ownerEmpNo || '',
ownerDisplayName: job.ownerDisplayName || '',
delivery: job.delivery && job.delivery.kind === 'im'

View file

@ -633,6 +633,13 @@ export function createHostService(options = {}) {
agentAccess: patch.agentAccess !== undefined
? patch.agentAccess
: (patch.agent_access !== undefined ? patch.agent_access : job.agentAccess),
permissionPreset: patch.permissionPreset !== undefined
? patch.permissionPreset
: (patch.permission_preset !== undefined
? patch.permission_preset
: (patch.accessMode !== undefined
? patch.accessMode
: (patch.access_mode !== undefined ? patch.access_mode : job.permissionPreset))),
delivery: patch.delivery !== undefined
? mergeDeliveryMention(job.delivery, patch.delivery)
: job.delivery,
@ -1307,7 +1314,7 @@ export function createHostService(options = {}) {
if (code === 'ALREADY_RUNNING') {
return write(409, { ok: false, error: error.message, code, message: error.message })
}
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD' || code === 'INVALID_DELIVERY' || code === 'IM_DELIVERY_FORBIDDEN' || code === 'IM_TARGET_FORBIDDEN' || code === 'IM_JOB_MISSING_CWD' || code === 'INVALID_WATCH' || code === 'INVALID_PROGRESS' || code === 'INVALID_REPORT' || code === 'INVALID_PERSIST_HISTORY' || code === 'INVALID_PROGRESS_PATH' || code === 'INVALID_ARCHIVE' || code === 'ARCHIVE_FAILED' || code === 'ARCHIVE_UNAVAILABLE' || code === 'INVALID_RETRIGGER') {
if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD' || code === 'INVALID_DELIVERY' || code === 'IM_DELIVERY_FORBIDDEN' || code === 'IM_TARGET_FORBIDDEN' || code === 'IM_JOB_MISSING_CWD' || code === 'INVALID_WATCH' || code === 'INVALID_PROGRESS' || code === 'INVALID_REPORT' || code === 'INVALID_PERSIST_HISTORY' || code === 'INVALID_PROGRESS_PATH' || code === 'INVALID_ARCHIVE' || code === 'ARCHIVE_FAILED' || code === 'ARCHIVE_UNAVAILABLE' || code === 'INVALID_RETRIGGER' || code === 'INVALID_PERMISSION_PRESET') {
return write(400, { ok: false, error: error.message, code, message: error.message })
}
if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale))
@ -1968,6 +1975,15 @@ export function makeLiveSessionPort(ctx) {
if (!agent || typeof agent.followup !== 'function') {
throw new Error('created agent has no followup')
}
try {
const wanted = typeof job?.permissionPreset === 'string' ? job.permissionPreset.trim() : ''
const permissionPresets = tryGet(ctx, 'permissionPresets')
if (wanted && permissionPresets && typeof permissionPresets.set === 'function' && agent.session) {
permissionPresets.set(agent.session, wanted)
}
} catch (error) {
console.warn?.(`[dsh-ops-cron] permission preset apply failed for job ${job?.id}: ${error instanceof Error ? error.message : error}`)
}
const message = {
id: randomUUID(),
role: 'user',

View file

@ -48,7 +48,7 @@ export {
workspaceVisibleIds,
} from './isolation.js'
export { wrapScheduledPrompt } from './prompt.js'
export { normalizeJobModel, normalizeAgentAccess, agentMayMutateJob, splitProviderModel } from './store.js'
export { normalizeJobModel, normalizeAgentAccess, agentMayMutateJob, normalizePermissionPreset, PERMISSION_PRESET_IDS, splitProviderModel } from './store.js'
export {
assertCanAccessJob,
canViewAllJobs,

View file

@ -97,6 +97,38 @@ export function agentMayMutateJob(job) {
return normalizeAgentAccess(job) === 'allow'
}
/** Host permission-preset ids (DSH 访问模式). Empty = inherit Host default for new sessions. */
export const PERMISSION_PRESET_IDS = Object.freeze([
'read-only',
'workspace-write',
'danger-full-access',
])
/**
* @param {unknown} input job or raw value
* @returns {''|'read-only'|'workspace-write'|'danger-full-access'}
*/
export function normalizePermissionPreset(input) {
const raw = input && typeof input === 'object' && !Array.isArray(input)
? (input.permissionPreset ?? input.permission_preset ?? input.accessMode ?? input.access_mode)
: input
const value = String(raw || '').trim().toLowerCase()
if (!value || value === 'default' || value === 'inherit' || value === 'host') return ''
if (value === 'readonly' || value === 'read_only' || value === 'view' || value === 'read-only') {
return 'read-only'
}
if (value === 'workspace' || value === 'workspace_write' || value === 'write' || value === 'workspace-write') {
return 'workspace-write'
}
if (value === 'full' || value === 'fullaccess' || value === 'full_access' || value === 'danger-full-access') {
return 'danger-full-access'
}
if (PERMISSION_PRESET_IDS.includes(value)) return value
const error = new Error(`permissionPreset must be empty|read-only|workspace-write|danger-full-access (got ${JSON.stringify(raw)})`)
error.code = 'INVALID_PERMISSION_PRESET'
throw error
}
/**
* 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-…).
@ -190,6 +222,7 @@ export function createJobRecord(input, state, now) {
settings.historyLimit,
)
const agentAccess = normalizeAgentAccess(input)
const permissionPreset = normalizePermissionPreset(input)
const job = {
id: String(input.id || newId()),
name,
@ -204,6 +237,7 @@ export function createJobRecord(input, state, now) {
reasoningEffort: model.reasoningEffort,
agentPreset,
agentAccess,
permissionPreset,
delivery,
mirrorToSession,
ownerEmpNo,

View file

@ -219,7 +219,7 @@ function jobLine(job) {
/** 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
const { agentAccess: _a, permissionPreset: _p, ...rest } = job
return rest
}

View file

@ -596,6 +596,51 @@ test('makeLiveSessionPort creates with default model and setup', async () => {
assert.equal(assembled.variables.model, 'deepseek-chat')
})
test('makeLiveSessionPort applies job permissionPreset when Host supports it', async () => {
const applied = []
const fakeSession = { id: 'sess-perm' }
const ctx = {
get(name) {
if (name === 'agentDefaultModel') {
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
}
if (name === 'permissionPresets') {
return {
set(session, preset) {
applied.push({ session, preset })
},
}
}
if (name === 'agents') {
return {
async create() {
const agent = createFakeAgent()
agent.session = fakeSession
return { agent, dispose: async () => {} }
},
get() {},
}
}
return undefined
},
}
const port = makeLiveSessionPort(ctx)
await port.createAndPrompt({
job: { name: 'elevated', timeoutMinutes: 1, permissionPreset: 'danger-full-access' },
run: {},
text: 'hi',
})
assert.deepEqual(applied, [{ session: fakeSession, preset: 'danger-full-access' }])
applied.length = 0
await port.createAndPrompt({
job: { name: 'default', timeoutMinutes: 1, permissionPreset: '' },
run: {},
text: 'hi',
})
assert.deepEqual(applied, [])
})
test('resolveDefaultModel fails loud when Models has no selection', async () => {
await assert.rejects(
() => resolveDefaultModel({ get: () => undefined }, { waitMs: 0 }),

View file

@ -473,13 +473,15 @@ test('agentAccess deny is hidden from tools and blocks pause/delete', async (t)
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' })
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 }, {}),
@ -492,6 +494,17 @@ test('agentAccess deny is hidden from tools and blocks pause/delete', async (t)
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', () => {