diff --git a/lib/client.js b/lib/client.js index 03900b7..6a5b970 100644 --- a/lib/client.js +++ b/lib/client.js @@ -276,8 +276,8 @@ window.__ModuleLoader__.load({ .dsh-ct-project:hover .dsh-ct-folder{display:none} .dsh-ct-arrow{transition:transform .15s var(--ds-ease-in-out,ease);display:inline-flex} .dsh-ct-arrowOpen{transform:rotate(90deg)} -.dsh-ct-project,.dsh-ct-session{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;padding:0 8px;display:flex!important;box-sizing:border-box;border:none;background:transparent;width:100%;font:inherit;text-align:left} -.dsh-ct-project{height:34px;gap:6px} +.dsh-ct-project,.dsh-ct-session{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;padding:0 8px;display:flex;box-sizing:border-box;border:none;background:transparent;width:100%;font:inherit;text-align:left} +.dsh-ct-project{height:34px;gap:6px;display:flex!important} .dsh-ct-session{height:32px;gap:0} .dsh-ct-project:hover,.dsh-ct-session:hover,.dsh-ct-session[data-on=true]{background:var(--dsw-alias-interactive-bg-hover)} .dsh-ct-project[data-paused=true] .dsh-ct-title{color:var(--dsw-alias-label-tertiary)} diff --git a/lib/host.js b/lib/host.js index d27169a..2620f94 100644 --- a/lib/host.js +++ b/lib/host.js @@ -26,6 +26,7 @@ import { import { assertCanAccessJob, canViewAllJobs, + claimUnassignedForViewer, filterJobsForIdentity, filterRunsForJobs, migrateJobOwners, @@ -79,6 +80,40 @@ function parseCookieHeader(header, name) { } +function empNoFromUserWorkspacePath(cwd) { + const norm = String(cwd || '').replace(/\\/g, '/') + const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) + return match ? decodeURIComponent(match[1]) : null +} + +function inferIdentityFromJobInput(input, getUdsAuth) { + const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + if (!uds) return null + const origin = input?.origin + const sessionId = origin?.kind === 'web' && typeof origin.sessionId === 'string' + ? origin.sessionId.trim() + : '' + if (sessionId && typeof uds.getSessionOwner === 'function') { + const empNo = uds.getSessionOwner(sessionId) + if (empNo) { + return { + empNo: String(empNo), + displayName: String(empNo), + permissions: {}, + } + } + } + const fromCwd = empNoFromUserWorkspacePath(input?.cwd) + if (fromCwd) { + return { + empNo: fromCwd, + displayName: fromCwd, + permissions: {}, + } + } + return null +} + function browserEmpNo(request) { const cookie = request?.headers?.cookie || '' return parseCookieHeader(cookie, 'PORTALSSOUser') @@ -137,6 +172,8 @@ function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { : null if (!raw) return ownerPath || '' if (canViewAllJobs(identity)) 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 if (ownerPath) return ownerPath return '' @@ -274,10 +311,11 @@ export function createHostService(options = {}) { const t = now() // Ignore client-supplied ids on create — otherwise POST/tools can overwrite. const { id: _ignoredId, ownerEmpNo: _ignoreOwner, ...safeInput } = input && typeof input === 'object' ? input : {} - if (identity?.empNo) { - safeInput.ownerEmpNo = identity.empNo - safeInput.ownerDisplayName = identity.displayName || identity.empNo - safeInput.cwd = sanitizeJobCwd(safeInput.cwd, identity, getUdsAuth, { forOwnerEmpNo: identity.empNo }) + let ownerIdentity = identity?.empNo ? identity : inferIdentityFromJobInput(safeInput, getUdsAuth) + if (ownerIdentity?.empNo) { + safeInput.ownerEmpNo = ownerIdentity.empNo + safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo + safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo }) } else if (safeInput.ownerEmpNo) { safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo) } else { @@ -616,7 +654,15 @@ export function createHostService(options = {}) { if (path === `${API_PREFIX}/jobs` && method === 'GET') { const identity = await requireIdentity(req, write, getUdsAuth) if (!identity) return - const state = await snapshot() + const uds = getUdsAuth() + const state = await withState((current) => { + const { state: next, changed } = claimUnassignedForViewer(current, identity, { + getSessionOwner: typeof uds?.getSessionOwner === 'function' + ? (sessionId) => uds.getSessionOwner(sessionId) + : undefined, + }) + return changed ? next : current + }) const jobs = filterJobsForIdentity(listJobs(state), identity).map(jobView) write(200, { ok: true, diff --git a/lib/ownership.js b/lib/ownership.js index 45a0f8f..8334d46 100644 --- a/lib/ownership.js +++ b/lib/ownership.js @@ -110,3 +110,40 @@ export function viewerPayload(identity) { workspacePath: identity.workspacePath || null, } } + +/** + * Claim unassigned jobs that clearly belong to the viewer (origin session / cwd). + * Returns { state, changed }. + */ +export function claimUnassignedForViewer(state, identity, deps = {}) { + if (!identity?.empNo || canViewAllJobs(identity)) { + return { state, changed: false } + } + const jobs = Array.isArray(state?.jobs) ? state.jobs : [] + let changed = false + const nextJobs = jobs.map((job) => { + if (!job || normalizeOwnerEmpNo(job.ownerEmpNo) !== UNASSIGNED_OWNER) return job + const sessionId = job.origin?.kind === 'web' && typeof job.origin.sessionId === 'string' + ? job.origin.sessionId.trim() + : '' + let mine = false + // Prefer explicit session ownership / user-workspaces path — do not trust a loose isUserPath. + if (sessionId && typeof deps.getSessionOwner === 'function') { + mine = deps.getSessionOwner(sessionId) === identity.empNo + } + if (!mine && job.cwd) { + const norm = String(job.cwd).replace(/\\/g, '/') + const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) + mine = !!(match && decodeURIComponent(match[1]) === identity.empNo) + } + if (!mine) return job + changed = true + return { + ...job, + ownerEmpNo: identity.empNo, + ownerDisplayName: identity.displayName || identity.empNo, + } + }) + if (!changed) return { state, changed: false } + return { state: { ...state, jobs: nextJobs }, changed: true } +} diff --git a/lib/tools.js b/lib/tools.js index 2268ffd..0ef28ed 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -15,6 +15,7 @@ import { resolveCreateAgentPreset } from './preset.js' import { assertCanAccessJob, filterJobsForIdentity, + jobVisibleToIdentity, UNASSIGNED_OWNER, } from './ownership.js' @@ -127,18 +128,67 @@ export function resolveCreateCwd(args, exec, { peer = null } = {}) { return sessionCwd } -function forbidForeignJob(job, peer) { - if (!peer?.botId) return job - if (jobVisibleToPeer(job, peer)) return job - const error = new Error('job not found or not owned by this chat') - error.code = 'NOT_FOUND' - throw error +function callerSessionId(exec) { + return String( + exec?.agent?.session?.id + || exec?.agent?.session?.header?.id + || exec?.sessionId + || exec?.agent?.sessionId + || '', + ).trim() } -async function requireOwnedJob(service, id, peer) { +function empNoFromUserWorkspacePath(cwd) { + const norm = String(cwd || '').replace(/\\/g, '/') + const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) + return match ? decodeURIComponent(match[1]) : null +} + +/** + * Resolve web caller identity for ownership. IM peers keep conversation scoping. + */ +export function resolveToolIdentity(exec, service) { + const uds = typeof service?.getUdsAuth === 'function' ? service.getUdsAuth() : null + const sessionId = callerSessionId(exec) + let empNo = null + if (sessionId && uds?.getSessionOwner) { + empNo = uds.getSessionOwner(sessionId) + } + if (!empNo) { + empNo = empNoFromUserWorkspacePath(callerWorkingDirectory(exec)) + } + if (!empNo) return null + return { + empNo: String(empNo), + displayName: String(empNo), + role: 'user', + permissions: { canViewAllSessions: false }, + } +} + +function forbidForeignJob(job, peer, identity = null) { + if (peer?.botId) { + if (jobVisibleToPeer(job, peer)) return job + const error = new Error('job not found or not owned by this chat') + error.code = 'NOT_FOUND' + throw error + } + if (identity?.empNo) { + assertCanAccessJob(job, identity) + return job + } + // No peer and no web identity: do not expose foreign jobs. + if (job?.ownerEmpNo && job.ownerEmpNo !== UNASSIGNED_OWNER) { + const error = new Error('job not found or not owned by this session') + error.code = 'NOT_FOUND' + throw error + } + return job +} + +async function requireOwnedJob(service, id, peer, identity = null) { const job = await service.getJob?.(id) - if (job) return forbidForeignJob(job, peer) - // Fallback when getJob is absent: list and find. + if (job) return forbidForeignJob(job, peer, identity) const jobs = await service.listJobs() const hit = (jobs || []).find((row) => row.id === id) if (!hit) { @@ -146,7 +196,7 @@ async function requireOwnedJob(service, id, peer) { error.code = 'NOT_FOUND' throw error } - return forbidForeignJob(hit, peer) + return forbidForeignJob(hit, peer, identity) } export function callerModelSelection(exec) { @@ -242,6 +292,7 @@ export function cronToolDefinitions(service, deps = {}) { const dshIm = getDshIm() try { const peer = await resolveCallerPeer(exec, dshIm) + const identity = resolveToolIdentity(exec, service) const delivery = await resolveCreateDelivery(args, exec, { dshIm }) const origin = await resolveCreateOrigin(args, exec, { dshIm }) const agentPreset = await resolveCreateAgentPreset(args, exec, { @@ -259,7 +310,7 @@ export function cronToolDefinitions(service, deps = {}) { delivery, ...origin ? { origin } : {}, agentPreset, - }) + }, identity) return { job } } catch (error) { const tz = args.timezone || args.time_zone || 'Asia/Shanghai' @@ -302,7 +353,8 @@ export function cronToolDefinitions(service, deps = {}) { async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) - let jobs = await service.listJobs() + const identity = resolveToolIdentity(exec, service) + let jobs = await service.listJobs(identity) 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 } @@ -325,9 +377,10 @@ export function cronToolDefinitions(service, deps = {}) { async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) + const identity = resolveToolIdentity(exec, service) const id = requireId(args) - await requireOwnedJob(service, id, peer) - const job = await service.pauseJob(id, false) + await requireOwnedJob(service, id, peer, identity) + const job = await service.pauseJob(id, false, identity) return { job } }, }, @@ -348,9 +401,10 @@ export function cronToolDefinitions(service, deps = {}) { async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) + const identity = resolveToolIdentity(exec, service) const id = requireId(args) - await requireOwnedJob(service, id, peer) - const job = await service.pauseJob(id, true) + await requireOwnedJob(service, id, peer, identity) + const job = await service.pauseJob(id, true, identity) return { job } }, }, @@ -379,9 +433,10 @@ export function cronToolDefinitions(service, deps = {}) { async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) + const identity = resolveToolIdentity(exec, service) const id = requireId(args) try { - await requireOwnedJob(service, id, peer) + await requireOwnedJob(service, id, peer, identity) await service.deleteJob(id) return { id, deleted: true } } catch (error) { diff --git a/test/host.test.js b/test/host.test.js index e23f216..b9e9138 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -865,6 +865,57 @@ test('host HTTP: per-user job isolation and super sees all', async (t) => { assert.equal(listTester2.body.jobs.length, 2) }) +test('GET /jobs claims unassigned jobs that match viewer cwd', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-claim-')) + 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'), + }) + const mine = await service.createJob({ + name: 'orphan-mine', + prompt: 'x', + schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' }, + cwd: '/tmp/plain-a', + }) + const other = await service.createJob({ + name: 'orphan-other', + prompt: 'y', + schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' }, + cwd: '/tmp/plain-b', + }) + // Simulate legacy rows: unassigned but cwd clearly under the viewer's workspace. + await service.store.mutate((current) => ({ + ...current, + jobs: current.jobs.map((job) => { + if (job.id === mine.id) { + return { + ...job, + ownerEmpNo: '__unassigned__', + ownerDisplayName: '', + cwd: '/tmp/user-workspaces/tester/ws', + } + } + if (job.id === other.id) { + return { + ...job, + ownerEmpNo: '__unassigned__', + ownerDisplayName: '', + cwd: '/tmp/user-workspaces/peer/ws', + } + } + return job + }), + })) + const http = await listen(service) + t.after(() => http.close()) + const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'tester' }) + assert.equal(listed.status, 200) + assert.equal(listed.body.jobs.length, 1) + assert.equal(listed.body.jobs[0].name, 'orphan-mine') + assert.equal(listed.body.jobs[0].ownerEmpNo, 'tester') +}) + test('migrateJobOwners assigns unassigned for legacy jobs', async () => { const { migrateJobOwners, UNASSIGNED_OWNER } = await import('../lib/ownership.js') const state = { diff --git a/test/ownership.test.js b/test/ownership.test.js index a983228..13da883 100644 --- a/test/ownership.test.js +++ b/test/ownership.test.js @@ -3,6 +3,7 @@ import { test } from 'node:test' import { assertCanAccessJob, canViewAllJobs, + claimUnassignedForViewer, filterJobsForIdentity, filterRunsForJobs, inferOwnerEmpNo, @@ -72,3 +73,23 @@ test('viewerPayload', () => { assert.equal(v.canViewAll, false) assert.equal(v.workspacePath, '/w/u1') }) + +test('claimUnassignedForViewer claims by session owner or user-workspaces cwd', () => { + const user = { empNo: 'u1', displayName: 'U1', permissions: { canViewAllSessions: false } } + const state = { + jobs: [ + { id: 'a', ownerEmpNo: UNASSIGNED_OWNER, origin: { kind: 'web', sessionId: 's1' }, cwd: '/tmp/other' }, + { id: 'b', ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u1/proj' }, + { id: 'c', ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u2/proj' }, + { id: 'd', ownerEmpNo: 'u2', cwd: '/data/user-workspaces/u1/x' }, + ], + } + const { changed, state: next } = claimUnassignedForViewer(state, user, { + getSessionOwner: (id) => (id === 's1' ? 'u1' : null), + }) + assert.equal(changed, true) + assert.equal(next.jobs[0].ownerEmpNo, 'u1') + assert.equal(next.jobs[1].ownerEmpNo, 'u1') + assert.equal(next.jobs[2].ownerEmpNo, UNASSIGNED_OWNER) + assert.equal(next.jobs[3].ownerEmpNo, 'u2') +}) diff --git a/test/tools.test.js b/test/tools.test.js index 2be7611..721ca9a 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createHostService } from '../lib/host.js' -import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from '../lib/tools.js' +import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, resolveCreateModel, resolveToolIdentity, scheduleFromArgs } from '../lib/tools.js' async function makeService(t) { const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-')) @@ -241,6 +241,25 @@ test('registerCronGuidance registers the scheduled-tasks skill only', () => { assert.equal(skills.length, 0) }) +test('cron_create stamps ownerEmpNo from user-workspaces cwd', async (t) => { + const service = await makeService(t) + const tools = byName(cronToolDefinitions(service)) + const exec = { agent: { session: { header: { cwd: '/tmp/user-workspaces/alice/app' } } } } + assert.equal(resolveToolIdentity(exec, service)?.empNo, 'alice') + const created = await tools.cron_create.execute({ + name: 'owned', + prompt: 'ping', + after_minutes: 5, + timezone: 'Asia/Shanghai', + }, exec) + assert.equal(created.job.ownerEmpNo, 'alice') + assert.equal(created.job.cwd, '/tmp/user-workspaces/alice/app') + + const listed = await tools.cron_list.execute({}, exec) + assert.equal(listed.count, 1) + assert.equal(listed.jobs[0].id, created.job.id) +}) + test('registerCronTools registers each definition and disposer unregisters', () => { const registered = [] const ctx = {