From a9540e1805fa5a27e5787b8a4d19700d9d3d81be Mon Sep 17 00:00:00 2001 From: Nekono Nana KAKKO KARI <3267314+nananek@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:43:12 +0900 Subject: [PATCH] fix: use real project name in notify footer for orchestrator sessions --- server/routes/groups.js | 10 ++++- server/routes/groups.test.js | 20 ++++++++-- server/ws/sessionManager.js | 12 +++--- server/ws/sessionManager.test.js | 63 ++++++++++++++++++++++++++++++++ server/ws/terminal.js | 8 ++++ 5 files changed, 102 insertions(+), 11 deletions(-) diff --git a/server/routes/groups.js b/server/routes/groups.js index cf23ada..8ed605d 100644 --- a/server/routes/groups.js +++ b/server/routes/groups.js @@ -17,7 +17,7 @@ import { randomUUID, createHash } from 'node:crypto'; import { mkdirSync, writeFileSync, statSync, rmSync, existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import * as groupManager from '../ws/groupManager.js'; import { createSession, getSession } from '../ws/sessionManager.js'; @@ -191,7 +191,9 @@ function memberSpecFromBody(spec) { // group's most recent orchestrator conversation (orchestratorDir is exclusive // to the project (cwd); concurrent groups for the same project are refused at // creation time, so at most one live group ever owns it at a time -- -// `resumeLast` maps 1:1 onto "the previous conversation"). +// `resumeLast` maps 1:1 onto "the previous conversation"). projectName is the +// real project's basename: the session's cwd is the hashed orchestratorDir, +// which must not leak into the notify footer (see sessionManager). export function orchestratorRestartSessionOpts({ group, app, model = null, sandboxOpts = null, mcpSocketPath }) { return { cwd: group.orchestratorDir, @@ -204,6 +206,7 @@ export function orchestratorRestartSessionOpts({ group, app, model = null, sandb resumeLast: true, groupId: group.id, groupRole: 'orchestrator', + projectName: group.cwd ? basename(group.cwd) : null, mcpSocketPath, }; } @@ -337,6 +340,9 @@ export async function groupsRoute(fastify, opts) { model: orchestrator.model ?? null, groupId, groupRole: 'orchestrator', + // The session's cwd is the hashed orchestratorDir; the notify footer + // must attribute the orchestrator to the real project instead. + projectName: basename(cwd), mcpSocketPath: controlBroker ? controlBroker.sockPath : null, }); if (orchRes.error || !orchRes.session) { diff --git a/server/routes/groups.test.js b/server/routes/groups.test.js index aca50b1..e9ff8b3 100644 --- a/server/routes/groups.test.js +++ b/server/routes/groups.test.js @@ -9,7 +9,7 @@ import { orchestratorRestartSessionOpts, orchestratorDirForCwd, groupExistsForCw test('orchestratorRestartSessionOpts: restart continues the last conversation', () => { const opts = orchestratorRestartSessionOpts({ - group: { id: 'group-1', orchestratorDir: '/tmp/orch/group-1' }, + group: { id: 'group-1', cwd: '/srv/proj', orchestratorDir: '/tmp/orch/group-1' }, app: 'claude', mcpSocketPath: '/tmp/mcp.sock', }); @@ -23,17 +23,19 @@ test('orchestratorRestartSessionOpts: restart continues the last conversation', assert.equal(opts.groupRole, 'orchestrator'); assert.equal(opts.mcpSocketPath, '/tmp/mcp.sock'); assert.equal(opts.resumeLast, true, 'restart must resume the group\u2019s previous conversation'); + assert.equal(opts.projectName, 'proj', 'notify attribution uses the real project basename, not the hashed orchestrator dir'); }); test('orchestratorRestartSessionOpts: resumeLast is independent of the app', () => { for (const app of ['claude', 'opencode']) { const opts = orchestratorRestartSessionOpts({ - group: { id: 'g', orchestratorDir: '/d' }, + group: { id: 'g', cwd: '/srv/proj', orchestratorDir: '/d' }, app, mcpSocketPath: '/s', }); assert.equal(opts.resumeLast, true, `resumeLast must be set for ${app}`); assert.equal(opts.app, app); + assert.equal(opts.projectName, 'proj', `the real project basename is attributed for ${app}`); } }); @@ -56,7 +58,7 @@ test('groupExistsForCwd matches an existing group for the same project', () => { test('orchestratorRestartSessionOpts carries model and member-specific sandbox options', () => { const opts = orchestratorRestartSessionOpts({ - group: { id: 'g', orchestratorDir: '/d' }, + group: { id: 'g', cwd: '/srv/proj', orchestratorDir: '/d' }, app: 'opencode', model: 'gpt-5', sandboxOpts: { gpg: true, sshAgent: false }, @@ -65,6 +67,7 @@ test('orchestratorRestartSessionOpts carries model and member-specific sandbox o assert.equal(opts.app, 'opencode'); assert.equal(opts.model, 'gpt-5'); assert.deepEqual(opts.sandboxOpts, { gpg: true, sshAgent: false }); + assert.equal(opts.projectName, 'proj'); }); // memberSpecFromBody is module-private, so the POST normalization contract is @@ -75,10 +78,19 @@ test('orchestratorRestartSessionOpts carries model and member-specific sandbox o // mcpBroker.test.js (open_tab) and through the groupManager precedence tests. test('orchestratorRestartSessionOpts: default model/sandboxOpts stay null (no flag leakage)', () => { const opts = orchestratorRestartSessionOpts({ - group: { id: 'g', orchestratorDir: '/d' }, + group: { id: 'g', cwd: '/srv/proj', orchestratorDir: '/d' }, app: 'claude', mcpSocketPath: '/s', }); assert.equal(opts.model, null); assert.equal(opts.sandboxOpts, null); }); + +test('orchestratorRestartSessionOpts: a group without a cwd keeps projectName null (no crash)', () => { + const opts = orchestratorRestartSessionOpts({ + group: { id: 'g', cwd: null, orchestratorDir: '/d' }, + app: 'claude', + mcpSocketPath: '/s', + }); + assert.equal(opts.projectName, null, 'missing group cwd must not throw basename()'); +}); diff --git a/server/ws/sessionManager.js b/server/ws/sessionManager.js index 2e48fb0..82993aa 100644 --- a/server/ws/sessionManager.js +++ b/server/ws/sessionManager.js @@ -99,7 +99,7 @@ function normalizeModel(model) { return typeof model === 'string' && model.length > 0 ? model : null; } -export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox, sandboxOpts, app, model, resumeLast, groupId = null, groupRole = null, mcpSocketPath = null }) { +export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox, sandboxOpts, app, model, resumeLast, groupId = null, groupRole = null, mcpSocketPath = null, projectName = null }) { const id = randomUUID(); // claude (and likely opencode) aborts immediately (SIGABRT, exit 134, no @@ -144,15 +144,17 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox // Per-connection identity for ccserver-notify (see notify.js / mcpBroker.js): // rides to the bridge as CCSERVER_NOTIFY_IDENTITY and becomes the "_from:" // footer on this session's notifications. Attribution only -- never an - // authorization input. projectName is basename(cwd) (createSession already - // refuses the filesystem root for agent sessions, so a meaningful name - // exists). + // authorization input. projectName defaults to basename(cwd) (createSession + // already refuses the filesystem root for agent sessions, so a meaningful + // name exists); an explicit projectName wins when the session's cwd is not + // the real project path (combo orchestrators run in a hashed orchestrator + // dir -- see routes/groups.js). const notifyIdentity = useNotify ? { sessionId: id, groupId, groupRole, cwd, - projectName: basename(cwd), + projectName: projectName ?? basename(cwd), app: sessionApp, } : null; diff --git a/server/ws/sessionManager.test.js b/server/ws/sessionManager.test.js index 1405300..17bcf80 100644 --- a/server/ws/sessionManager.test.js +++ b/server/ws/sessionManager.test.js @@ -599,3 +599,66 @@ test('idle timer no longer sends input_needed, but still advances the settle gat try { rmSync(cfgDir, { recursive: true, force: true }); } catch { /* ignore */ } } }); + +// notifyIdentity attribution (see notify.js / mcpConfig.js): the per-session +// identity rides to the bridge as the CCSERVER_NOTIFY_IDENTITY env. An +// explicit projectName overrides basename(cwd) -- a combo orchestrator's cwd +// is a hashed orchestrator dir (routes/groups.js) and must not leak into the +// notify footer; without one the cwd basename is used (existing behavior). A +// fake claude binary echoes the env var so the injected identity is +// observable from the session's output buffer. +test('createSession notify identity: explicit projectName wins, cwd basename is the fallback', async () => { + const binDir = mkdtempSync(join(tmpdir(), 'ccserver-fake-agent-')); + const fakeBin = join(binDir, 'fake-claude'); + writeFileSync(fakeBin, '#!/bin/bash\nprintf "%s\\n" "$CCSERVER_NOTIFY_IDENTITY"\n', { mode: 0o755 }); + const cfgDir = mkdtempSync(join(tmpdir(), 'ccserver-fake-cfg-')); + const cfgPath = join(cfgDir, 'sandbox.config.json'); + writeFileSync(cfgPath, JSON.stringify({ + docker: false, + gitBroker: false, + notify: { discordWebhook: 'https://discord.example/hook' }, + })); + const prevBin = process.env.CCSERVER_CLAUDE_BIN; + const prevCfg = process.env.CCSERVER_SANDBOX_CONFIG; + process.env.CCSERVER_CLAUDE_BIN = fakeBin; + process.env.CCSERVER_SANDBOX_CONFIG = cfgPath; + const notify = await import('./notify.js'); + await notify.ensureNotifyBroker(); + const ids = []; + const identityOf = (s) => { + const line = s.outputBuffer.join('').split('\n').map((l) => l.trim()).find((l) => l.startsWith('{')); + return line ? JSON.parse(line) : null; + }; + try { + const named = sessionManager.createSession({ + cwd: '/tmp', cols: 80, rows: 24, shell: false, sandbox: false, app: 'claude', + projectName: 'real-proj', + }); + assert.ok(named.session, 'agent session should spawn'); + ids.push(named.sessionId); + await sleep(500); + const namedIdentity = identityOf(named.session); + assert.ok(namedIdentity, 'notify identity must be injected (CCSERVER_NOTIFY_IDENTITY env)'); + assert.equal(namedIdentity.projectName, 'real-proj', 'the explicit projectName wins over the cwd basename'); + assert.equal(namedIdentity.cwd, '/tmp'); + + const fallback = sessionManager.createSession({ + cwd: '/tmp', cols: 80, rows: 24, shell: false, sandbox: false, app: 'claude', + }); + assert.ok(fallback.session, 'agent session should spawn'); + ids.push(fallback.sessionId); + await sleep(500); + const fallbackIdentity = identityOf(fallback.session); + assert.ok(fallbackIdentity, 'notify identity must be injected'); + assert.equal(fallbackIdentity.projectName, 'tmp', 'without an explicit projectName the cwd basename is used'); + } finally { + for (const id of ids) sessionManager.destroySession(id, { keepSchedule: false }); + notify.stopNotifyBroker(); + if (prevBin === undefined) delete process.env.CCSERVER_CLAUDE_BIN; + else process.env.CCSERVER_CLAUDE_BIN = prevBin; + if (prevCfg === undefined) delete process.env.CCSERVER_SANDBOX_CONFIG; + else process.env.CCSERVER_SANDBOX_CONFIG = prevCfg; + try { rmSync(binDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { rmSync(cfgDir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/server/ws/terminal.js b/server/ws/terminal.js index 4f4dfdd..dc50b61 100644 --- a/server/ws/terminal.js +++ b/server/ws/terminal.js @@ -1,4 +1,6 @@ import { homedir } from 'node:os'; +import { basename } from 'node:path'; +import { getGroup } from './groupManager.js'; import { createSession, getSession, @@ -84,6 +86,11 @@ export async function terminalWs(fastify, opts) { } // An orchestrator re-launched through the browser's re-init path // still reaches the group via the re-created control broker above. + // Its init cwd is the hashed orchestrator dir, so attribute the + // session to the group's real project path (workers' cwd IS the + // project dir, so the same override is harmless for them). + const group = groupId ? getGroup(groupId) : null; + const projectName = group?.cwd ? basename(group.cwd) : undefined; const result = createSession({ cwd: msg.cwd || homedir(), @@ -98,6 +105,7 @@ export async function terminalWs(fastify, opts) { resumeLast: !!msg.resume, groupId, groupRole, + projectName, mcpSocketPath, }); if (result.error) {