mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-08 23:20:45 +08:00
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:
parent
ad6e90f197
commit
8cf624ce7f
8 changed files with 140 additions and 4 deletions
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
18
lib/host.js
18
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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
34
lib/store.js
34
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue