From 7a2a673ac26b5316dcc7392a77e430be8c0a6f5e Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 11:09:56 +0800 Subject: [PATCH] Keep foreign cron cwd for super_admin via live role resolve and session default. Co-authored-by: Cursor --- lib/client.js | 8 +++- lib/host.js | 84 ++++++++++++++++++++++++++++++++++++++---- lib/index.d.ts | 1 + lib/index.js | 1 + lib/ownership.js | 20 ++++++++-- lib/tools.js | 5 ++- package.json | 2 +- test/host.test.js | 69 ++++++++++++++++++++++++++++++++++ test/ownership.test.js | 17 ++++++++- 9 files changed, 189 insertions(+), 18 deletions(-) diff --git a/lib/client.js b/lib/client.js index b5e4107..82ed51d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -978,12 +978,16 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return String(row?.cwd || '').trim() } - /** Prefer provisioned viewer workspace over the currently open project. */ + /** Prefer open session cwd for elevated viewers; else provisioned path. */ function preferredNewJobCwd(viewer, sessionCwd) { + const session = String(sessionCwd || '').trim() const provisioned = String(viewer?.workspacePath || '').trim() + if (viewer?.canViewAll && session) return session + const role = String(viewer?.role || '').trim() + if ((role === 'super_admin' || role === 'fallback_admin') && session) return session if (viewer?.mode === 'multi' && provisioned) return provisioned if (provisioned) return provisioned - return String(sessionCwd || '').trim() + return session } function cwdSelectValue(cwd, workspaces) { diff --git a/lib/host.js b/lib/host.js index a9d68cd..2912ef8 100644 --- a/lib/host.js +++ b/lib/host.js @@ -30,6 +30,7 @@ import { claimUnassignedForViewer, filterJobsForIdentity, filterRunsForJobs, + isElevatedCronRole, isMultiUserIdentity, localIdentity, migrateJobOwners, @@ -175,6 +176,67 @@ async function requireIdentity(request, write, getUdsAuth) { return { ...identity, mode: 'multi', lang: identity.lang || locale } } +/** + * Whether this identity / job owner may keep an explicit cwd outside the + * provisioned tree. Re-resolves via uds-auth so stale tool identities (role + * missing permissions, or Host with an older caller path) still work. + */ +function allowsForeignJobCwd(identity, getUdsAuth, ownerEmpNo) { + if (canViewAllJobs(identity)) return true + const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + const empNo = String(ownerEmpNo || identity?.empNo || '').trim() + if (!empNo || empNo.startsWith('__') || !uds) return false + if (typeof uds.resolveIdentityForEmpNo === 'function') { + const live = uds.resolveIdentityForEmpNo(empNo) + if (live && canViewAllJobs(live)) return true + } + return false +} + +/** Merge live role/permissions onto a caller identity when uds-auth can look them up. */ +function enrichIdentity(identity, getUdsAuth) { + if (!isMultiUserIdentity(identity)) return identity + const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + if (typeof uds?.resolveIdentityForEmpNo === 'function') { + const live = uds.resolveIdentityForEmpNo(identity.empNo) + if (live?.empNo) { + const canView = !!( + live.permissions?.canViewAllSessions + || identity.permissions?.canViewAllSessions + || isElevatedCronRole(live.role) + || isElevatedCronRole(identity.role) + ) + return { + ...identity, + role: live.role || identity.role, + displayName: live.displayName || identity.displayName || identity.empNo, + permissions: { + ...(identity.permissions || {}), + ...(live.permissions || {}), + canViewAllSessions: canView, + canCreateWorkspace: !!( + live.permissions?.canCreateWorkspace + || identity.permissions?.canCreateWorkspace + || canView + ), + }, + workspacePath: live.workspacePath || identity.workspacePath || null, + } + } + } + if (isElevatedCronRole(identity.role)) { + return { + ...identity, + permissions: { + ...(identity.permissions || {}), + canViewAllSessions: true, + canCreateWorkspace: true, + }, + } + } + return identity +} + function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { const raw = typeof cwd === 'string' ? cwd.trim() : '' const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null @@ -183,7 +245,7 @@ function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { ? uds.getProvisionedWorkspacePath(owner) : null if (!raw) return ownerPath || '' - if (canViewAllJobs(identity)) return raw + if (allowsForeignJobCwd(identity, getUdsAuth, owner)) return raw // Without uds-auth, keep the caller cwd (tools may stamp owner from path only). if (!uds) return raw if (owner && uds?.isUserPath?.(owner, raw)) return raw @@ -350,9 +412,12 @@ export function createHostService(options = {}) { safeInput.ownerDisplayName = '' // Keep cwd as the bot workspace path; do not sanitize via empNo. } else { - const ownerIdentity = isMultiUserIdentity(identity) - ? identity - : inferIdentityFromJobInput(safeInput, getUdsAuth) + const ownerIdentity = enrichIdentity( + isMultiUserIdentity(identity) + ? identity + : inferIdentityFromJobInput(safeInput, getUdsAuth), + getUdsAuth, + ) if (isMultiUserIdentity(ownerIdentity)) { const delivery = normalizeDelivery(safeInput.delivery || { kind: 'dsh' }) assertDeliveryAllowedForIdentity(delivery, ownerIdentity) @@ -412,18 +477,19 @@ export function createHostService(options = {}) { ownerDisplayName: job.ownerDisplayName || '', } if (patch._identity) { + const enriched = enrichIdentity(patch._identity, getUdsAuth) if (patch.delivery !== undefined) { nextInput.delivery = mergeDeliveryMention(job.delivery, patch.delivery) - assertDeliveryAllowedForIdentity(nextInput.delivery, patch._identity) + assertDeliveryAllowedForIdentity(nextInput.delivery, enriched) } nextInput.cwd = sanitizeJobCwd( nextInput.cwd, - patch._identity, + enriched, getUdsAuth, { forOwnerEmpNo: nextInput.ownerEmpNo }, ) } - if (patch.ownerEmpNo !== undefined && canViewAllJobs(patch._identity)) { + if (patch.ownerEmpNo !== undefined && canViewAllJobs(enrichIdentity(patch._identity, getUdsAuth))) { nextInput.ownerEmpNo = normalizeOwnerEmpNo(patch.ownerEmpNo) if (patch.ownerDisplayName !== undefined) { nextInput.ownerDisplayName = String(patch.ownerDisplayName || '').trim().slice(0, 80) @@ -1004,16 +1070,18 @@ function sessionCwdOf(ctx, sessionId) { /** * True when the job owner may keep an explicit cwd outside their provisioned tree - * (super_admin / fallback_admin via canViewAllSessions or canCreateWorkspace). + * (super_admin / fallback_admin via canViewAllSessions, canCreateWorkspace, or role). */ export function ownerAllowsForeignCwd(ownerEmpNo, uds) { const empNo = String(ownerEmpNo || '').trim() if (!empNo || empNo.startsWith('__') || !uds) return false if (typeof uds.resolveIdentityForEmpNo === 'function') { const identity = uds.resolveIdentityForEmpNo(empNo) + if (canViewAllJobs(identity)) return true return !!( identity?.permissions?.canViewAllSessions || identity?.permissions?.canCreateWorkspace + || isElevatedCronRole(identity?.role) ) } return false diff --git a/lib/index.d.ts b/lib/index.d.ts index 560bba0..4679e3e 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -24,6 +24,7 @@ export function localIdentity(overrides?: object): object export function claimUnassignedForViewer(state: object, identity: object, deps?: object): { state: object, changed: boolean } export function jobVisibleToIdentity(job: object, identity: object): boolean export function canViewAllJobs(identity: object): boolean +export function isElevatedCronRole(role?: string | null): boolean export function assertCanAccessJob(job: object, identity: object): object export function filterJobsForIdentity(jobs: object[], identity: object): object[] export function migrateJobOwners(state: object, deps?: object): { state: object, changed: boolean } diff --git a/lib/index.js b/lib/index.js index 5e86c87..26774b9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -54,6 +54,7 @@ export { canViewAllJobs, claimUnassignedForViewer, filterJobsForIdentity, + isElevatedCronRole, isMultiUserIdentity, jobVisibleToIdentity, LOCAL_EMP_NO, diff --git a/lib/ownership.js b/lib/ownership.js index ed06be4..f3a0b77 100644 --- a/lib/ownership.js +++ b/lib/ownership.js @@ -32,9 +32,17 @@ export function localIdentity(overrides = {}) { } } +/** Roles that may keep foreign cwd and see every job (aligns with uds-auth). */ +export function isElevatedCronRole(role) { + const value = String(role || '').trim() + return value === 'super_admin' || value === 'fallback_admin' +} + export function canViewAllJobs(identity) { if (!isMultiUserIdentity(identity)) return true - return !!identity?.permissions?.canViewAllSessions + if (identity?.permissions?.canViewAllSessions) return true + // Defense: some callers stamp role without copying permissions. + return isElevatedCronRole(identity?.role) } export function jobVisibleToIdentity(job, identity) { @@ -141,14 +149,18 @@ export function viewerPayload(identity) { /** * Default cwd when creating a job in the sidebar. - * Prefer the viewer's provisioned workspace over whatever project the session - * happens to have open (admins often sit in an unrelated clone). + * - super_admin / fallback_admin: prefer the currently open session workspace + * (they often work in a shared tree like D:\\code\\gpt, not their empty provisioned dir). + * - ordinary multi-user: prefer provisioned path so a random open clone is not stamped. */ export function preferredNewJobCwd({ viewer = null, sessionCwd = '' } = {}) { + const session = String(sessionCwd || '').trim() const provisioned = String(viewer?.workspacePath || '').trim() + if (viewer?.canViewAll && session) return session + if (isElevatedCronRole(viewer?.role) && session) return session if (viewer?.mode === 'multi' && provisioned) return provisioned if (provisioned) return provisioned - return String(sessionCwd || '').trim() + return session } /** diff --git a/lib/tools.js b/lib/tools.js index a229f9d..113b587 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -174,13 +174,14 @@ export function resolveToolIdentity(exec, service) { if (typeof uds?.resolveIdentityForEmpNo === 'function') { const resolved = uds.resolveIdentityForEmpNo(id) if (resolved?.empNo) { + const elevated = resolved.role === 'super_admin' || resolved.role === 'fallback_admin' return { empNo: String(resolved.empNo), displayName: String(resolved.displayName || resolved.empNo), role: resolved.role || 'user', permissions: { - canViewAllSessions: !!resolved.permissions?.canViewAllSessions, - canCreateWorkspace: !!resolved.permissions?.canCreateWorkspace, + canViewAllSessions: !!(resolved.permissions?.canViewAllSessions || elevated), + canCreateWorkspace: !!(resolved.permissions?.canCreateWorkspace || elevated), }, workspacePath: resolved.workspacePath || uds.getProvisionedWorkspacePath?.(id) || null, } diff --git a/package.json b/package.json index 0d02a93..4dddbc3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ops-cron", "description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.", - "version": "0.1.13", + "version": "0.1.14", "private": false, "type": "module", "repository": { diff --git a/test/host.test.js b/test/host.test.js index 89962c5..e364ad8 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -671,6 +671,75 @@ test('super_admin createJob keeps explicit foreign cwd; normal user is clamped', assert.equal(userJob.cwd, '/tmp/user-workspaces/tester') }) +test('createJob elevates via live resolveIdentityForEmpNo when caller permissions are empty', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const service = createTestHost({ + filePath: join(dir, 'store.json'), + now: () => Date.parse('2026-08-24T01:00:00.000Z'), + udsAuthOptions: { strictPath: true }, + sessionPort: { + async createAndPrompt() { + return { sessionId: 's1', status: 'succeeded', summary: 'ok' } + }, + async archiveSession() {}, + }, + }) + // Stale tool identity: empNo only, wrong role flag — must re-resolve from uds-auth. + const stale = { + empNo: '10329667', + role: 'user', + displayName: '10329667', + permissions: { canViewAllSessions: false }, + workspacePath: '/tmp/deepseek-harness/10329667', + } + const job = await service.createJob({ + name: 'gpt-via-live', + prompt: 'work in gpt', + cwd: 'D:/code/gpt', + schedule: { kind: 'at', at: '2026-08-24T02:00:00.000Z', timezone: 'UTC' }, + }, stale) + assert.equal(job.cwd, 'D:/code/gpt') +}) + +test('createJob keeps foreign cwd when only role is stamped super_admin', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const service = createTestHost({ + filePath: join(dir, 'store.json'), + now: () => Date.parse('2026-08-24T01:00:00.000Z'), + udsAuth: { + getProvisionedWorkspacePath(empNo) { + return empNo === '10329667' + ? '/tmp/deepseek-harness/10329667' + : `/tmp/user-workspaces/${empNo}` + }, + isUserPath(empNo, candidatePath) { + const root = this.getProvisionedWorkspacePath(empNo) + const cand = String(candidatePath || '').replace(/\\/g, '/') + const normRoot = String(root).replace(/\\/g, '/') + return cand === normRoot || cand.startsWith(`${normRoot}/`) + }, + }, + sessionPort: { + async createAndPrompt() { + return { sessionId: 's1', status: 'succeeded', summary: 'ok' } + }, + }, + }) + const job = await service.createJob({ + name: 'role-only', + prompt: 'p', + cwd: 'D:/code/gpt', + schedule: { kind: 'at', at: '2026-08-24T02:00:00.000Z', timezone: 'UTC' }, + }, { + empNo: '10329667', + role: 'super_admin', + permissions: {}, + }) + assert.equal(job.cwd, 'D:/code/gpt') +}) + test('live fire uses super_admin job cwd outside provisioned tree', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) diff --git a/test/ownership.test.js b/test/ownership.test.js index 9598ebf..49883be 100644 --- a/test/ownership.test.js +++ b/test/ownership.test.js @@ -125,14 +125,29 @@ test('claimUnassignedForViewer claims by session owner or user-workspaces cwd', assert.equal(next.jobs[5].ownerEmpNo, UNASSIGNED_OWNER) }) -test('preferredNewJobCwd prefers provisioned viewer workspace over session cwd', () => { +test('preferredNewJobCwd: users prefer provisioned; elevated prefer session', () => { assert.equal(preferredNewJobCwd({ viewer: { mode: 'multi', workspacePath: '/tmp/user-workspaces/10329667' }, sessionCwd: 'D:/code/deepseek-harness/clone', }), '/tmp/user-workspaces/10329667') + assert.equal(preferredNewJobCwd({ + viewer: { + mode: 'multi', + canViewAll: true, + role: 'super_admin', + workspacePath: '/tmp/deepseek-harness/10329667', + }, + sessionCwd: 'D:/code/gpt', + }), 'D:/code/gpt') assert.equal(preferredNewJobCwd({ viewer: { mode: 'local', workspacePath: null }, sessionCwd: '/tmp/ws-app', }), '/tmp/ws-app') assert.equal(preferredNewJobCwd({}), '') }) + +test('canViewAllJobs treats super_admin role even without permissions flags', () => { + assert.equal(canViewAllJobs({ empNo: '10329667', role: 'super_admin', permissions: {} }), true) + assert.equal(canViewAllJobs({ empNo: 'tester', role: 'user', permissions: {} }), false) + assert.equal(canViewAllJobs({ empNo: 'a', role: 'fallback_admin' }), true) +})