Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions server/routes/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 16 additions & 4 deletions server/routes/groups.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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}`);
}
});

Expand All @@ -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 },
Expand All @@ -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
Expand All @@ -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()');
});
12 changes: 7 additions & 5 deletions server/ws/sessionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
63 changes: 63 additions & 0 deletions server/ws/sessionManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
}
});
8 changes: 8 additions & 0 deletions server/ws/terminal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { homedir } from 'node:os';
import { basename } from 'node:path';
import { getGroup } from './groupManager.js';
import {
createSession,
getSession,
Expand Down Expand Up @@ -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(),
Expand All @@ -98,6 +105,7 @@ export async function terminalWs(fastify, opts) {
resumeLast: !!msg.resume,
groupId,
groupRole,
projectName,
mcpSocketPath,
});
if (result.error) {
Expand Down
Loading