diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 1df272367e..256a609af0 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -6004,10 +6004,11 @@ async function invalidateRoutineOwner(profile) { function selectRoutineJobs(data, error, lastJobs, bot) { const live = Array.isArray(data?.jobs) ? data.jobs : null const all = live ?? (error ? lastJobs : []) + const scopedToBot = normalizedProfileName(data?.scoped) === normalizedProfileName(bot) return { live, all, - jobs: all.filter(job => routineBot(job) === bot) + jobs: scopedToBot ? all : all.filter(job => routineBot(job) === bot) } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/routines-error.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/routines-error.test.mjs index 8510c557d8..3c1d1f9c30 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/routines-error.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/routines-error.test.mjs @@ -43,6 +43,45 @@ test('unit: a live list is filtered to the current bot', () => { assert.equal(shown[0].job_id, '1') }) +test('regression: a profile-scoped list shows every job owned by that bot profile', () => { + const profileJobs = [ + { name: 'ordinary profile cronjob', job_id: 'legacy' }, + { name: '[bot:ops] Bot Mode routine', job_id: 'routine' } + ] + const view = load().__api.selectRoutineJobs({ jobs: profileJobs, scoped: 'ops' }, null, [], 'ops') + + assert.deepEqual( + Array.from(view.jobs, job => job.job_id), + ['legacy', 'routine'] + ) +}) + +test('compatibility: an unmarked list keeps tag filtering for older gateways', () => { + const profileJobs = [ + { name: 'ordinary launch-profile cronjob', job_id: 'legacy' }, + { name: '[bot:ops] Bot Mode routine', job_id: 'routine' } + ] + const view = load().__api.selectRoutineJobs({ jobs: profileJobs }, null, [], 'ops') + + assert.deepEqual( + Array.from(view.jobs, job => job.job_id), + ['routine'] + ) +}) + +test('compatibility: a stale scope marker cannot leak another profile\'s jobs', () => { + const profileJobs = [ + { name: 'ordinary research cronjob', job_id: 'research' }, + { name: '[bot:ops] Bot Mode routine', job_id: 'routine' } + ] + const view = load().__api.selectRoutineJobs({ jobs: profileJobs, scoped: 'research' }, null, [], 'ops') + + assert.deepEqual( + Array.from(view.jobs, job => job.job_id), + ['routine'] + ) +}) + test('unit: a failed refresh keeps the last good list', () => { const view = load().__api.selectRoutineJobs(undefined, new Error('down'), jobs, 'ops') assert.equal(view.live, null) diff --git a/tests/test_cron_manage_profile_scope.py b/tests/test_cron_manage_profile_scope.py index cdad2e8c7b..8ba0f63041 100644 --- a/tests/test_cron_manage_profile_scope.py +++ b/tests/test_cron_manage_profile_scope.py @@ -47,6 +47,7 @@ def test_cron_manage_profile_reads_that_profiles_store(tmp_path, monkeypatch): ) assert "result" in resp, resp + assert resp["result"]["scoped"] == "botA" names = [j.get("name") for j in resp["result"]["jobs"]] assert "botA-only-job" in names diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 9042340bc2..1e85bba2b3 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1699,15 +1699,19 @@ def _(rid, params: dict) -> dict: if action == "list": # Paused jobs are excluded by default, which reads as deletion in # any UI with an enable/disable toggle — forward the flag. - return _ok( - rid, - json.loads( - cronjob( - action="list", - include_disabled=is_truthy_value(params.get("include_disabled", False)), - ) - ), + result = json.loads( + cronjob( + action="list", + include_disabled=is_truthy_value(params.get("include_disabled", False)), + ) ) + # This marker proves the gateway honored the optional profile + # scope. New clients may therefore treat every returned job as + # owned by that profile; older gateways omit it, preserving the + # safe [bot:] compatibility filter. + if profile: + result["scoped"] = profile + return _ok(rid, result) if action == "add": return _ok( rid,