From 8cf624ce7f39f4141acad1ea3ebf787e927d11d1 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 22:08:39 +0800 Subject: [PATCH] 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 --- lib/client.js | 27 +++++++++++++++++++++++++++ lib/fire.js | 1 + lib/host.js | 18 +++++++++++++++++- lib/index.js | 2 +- lib/store.js | 34 ++++++++++++++++++++++++++++++++++ lib/tools.js | 2 +- test/host.test.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ test/tools.test.js | 15 ++++++++++++++- 8 files changed, 140 insertions(+), 4 deletions(-) diff --git a/lib/client.js b/lib/client.js index 1b42ca7..a62f296 100644 --- a/lib/client.js +++ b/lib/client.js @@ -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' } diff --git a/lib/fire.js b/lib/fire.js index 846a8d2..ac875d8 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -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' diff --git a/lib/host.js b/lib/host.js index 999e6bb..4443c98 100644 --- a/lib/host.js +++ b/lib/host.js @@ -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', diff --git a/lib/index.js b/lib/index.js index 66e8314..aadfe65 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, normalizeAgentAccess, agentMayMutateJob, splitProviderModel } from './store.js' +export { normalizeJobModel, normalizeAgentAccess, agentMayMutateJob, normalizePermissionPreset, PERMISSION_PRESET_IDS, splitProviderModel } from './store.js' export { assertCanAccessJob, canViewAllJobs, diff --git a/lib/store.js b/lib/store.js index a332c12..46ca589 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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, diff --git a/lib/tools.js b/lib/tools.js index 22bf5a9..2c65723 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -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 } diff --git a/test/host.test.js b/test/host.test.js index 71988b7..0a0a0b6 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -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 }), diff --git a/test/tools.test.js b/test/tools.test.js index 44e4daf..49d297b 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -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', () => {