fix(skills): the dispatcher gate reads the payload cwd; a symlink is not HOME

Codex review of b0583f5:

- The dispatcher's `contribute-check` and `pending-hint` handlers asked
  the gate about the process's directory, trusting hook-dispatch's
  `chdir`; when that failed, the launcher's config decided. They now pass
  `resolveHookCwd(stdin)`, as the legacy command does.
- The HOME exception for a non-project scope compared the config file's
  real path, so a project config symlinked to ~/.teamai/config.yaml passed
  for the user config. It is now decided by the project's location: its
  root is HOME.
This commit is contained in:
Saul Moro
2026-09-23 14:21:44 +02:00
parent b0583f5681
commit 15a5b5d545
5 changed files with 63 additions and 12 deletions
@@ -174,6 +174,26 @@ describe('findUnreadableProjectConfig', () => {
vi.unstubAllEnvs();
});
it('names a project config that is a symlink to the user config', async () => {
// The HOME exception is about where the project is, not where the file points.
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-symlink-home-'));
vi.stubEnv('HOME', home);
vi.stubEnv('USERPROFILE', home);
const userConfig = path.join(home, '.teamai', 'config.yaml');
fs.mkdirSync(path.dirname(userConfig), { recursive: true });
fs.writeFileSync(userConfig, 'repo:\n localPath: /x\n remote: https://example.test/a.git\nusername: t\nscope: user\n');
const configPath = path.join(dir, '.teamai', 'config.yaml');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.symlinkSync(userConfig, configPath);
try {
expect(await findUnreadableProjectConfig(dir)).toContain(configPath);
} finally {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
}
});
it('names a broken partition config even when the legacy .teamai/ config behind it loads', async () => {
// The partition is authoritative; detection skips it when broken and lands
// on the legacy config, which may belong to another team.
+24 -1
View File
@@ -87,6 +87,9 @@ const mockFindUnreadableProjectConfig = vi.fn().mockResolvedValue(null);
vi.mock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
autoDetectInit: mockAutoDetectInit,
// A payload cwd that no longer exists (these tests use '/x') holds no
// project config, so the gate asks the user config: the same mocked one.
requireInit: mockAutoDetectInit,
findUnreadableProjectConfig: mockFindUnreadableProjectConfig,
}));
@@ -440,11 +443,31 @@ describe('hook-handlers registry', () => {
mockFindUnreadableProjectConfig.mockResolvedValueOnce('/x/.teamai/config.yaml: bad indentation');
mockContributeCheckForSession.mockClear();
const result = await handler.execute({ session_id: 's5c', cwd: '/x' }, 'claude');
// An existing directory: a deleted one holds no project config to be unreadable.
const result = await handler.execute({ session_id: 's5c', cwd: process.cwd() }, 'claude');
expect(result).toBeNull();
expect(mockContributeCheckForSession).not.toHaveBeenCalled();
});
it('contribute-check handler asks the gate about the payload cwd, not the directory the process is in', async () => {
// hook-dispatch changes into the payload cwd, but that can fail; the gate
// must not then read wherever the process started.
const registry = buildHandlerRegistry();
const handler = registry.find(
(r) => r.event === 'stop' && r.handler.name === 'contribute-check',
)!.handler;
mockFindUnreadableProjectConfig.mockImplementation(async (cwd?: string) =>
cwd === undefined ? '/launcher/.teamai/config.yaml: bad indentation' : null);
mockContributeCheckForSession.mockResolvedValueOnce({ hint: '[teamai] do share' });
try {
const result = await handler.execute({ session_id: 's5e', cwd: process.cwd() }, 'claude');
expect(result).toContain('do share');
} finally {
mockFindUnreadableProjectConfig.mockReset();
mockFindUnreadableProjectConfig.mockResolvedValue(null);
}
});
it('contribute-check handler withholds the reminder, without failing the turn, when the gate itself faults', async () => {
const registry = buildHandlerRegistry();
const handler = registry.find(
+11 -4
View File
@@ -19,6 +19,7 @@ import {
} from './types.js';
import { readFileSafe, readJson, writeFile, writeJson, expandHome, pathExists } from './utils/fs.js';
import { resolveAnchors } from './utils/git.js';
import { getUserHome } from './utils/home.js';
import { resolvePartitionDir, writeAnchorFile } from './utils/partition.js';
import { log } from './utils/logger.js';
import { loadRolesManifest } from './roles.js';
@@ -437,7 +438,7 @@ export async function readConfigFrom(
// Run from HOME, `<cwd>/.teamai/config.yaml` is the user config itself.
// Anywhere else a config here that is not scope: project cannot say which
// project it serves, and detection would read past it to the user config.
if (!isUserConfigFile(configPath)) {
if (!isUserTeamaiDir(dataHomeDir, projectRoot)) {
onUnreadable?.(configPath, `it is scope: ${config.scope}, but a config inside a project must be scope: project`);
}
return null;
@@ -471,8 +472,13 @@ export async function readConfigFrom(
}
}
/** Whether `configPath` is the user config, comparing real paths (a tmp HOME and the cwd can differ by a symlink). */
function isUserConfigFile(configPath: string): boolean {
/**
* Whether `dataHomeDir` is `<HOME>/.teamai`, decided by where the project is:
* its root is HOME. Real paths, since a tmp HOME and the cwd can differ by a
* symlink; but never the file's target, or a project config symlinked to the
* user config would pass for it.
*/
function isUserTeamaiDir(dataHomeDir: string, projectRoot: string): boolean {
const real = (p: string): string => {
try {
return fs.realpathSync(p);
@@ -480,7 +486,8 @@ function isUserConfigFile(configPath: string): boolean {
return path.resolve(p);
}
};
return real(configPath) === real(expandHome(getUserConfigPath()));
return path.resolve(dataHomeDir) === path.join(path.resolve(projectRoot), '.teamai')
&& real(projectRoot) === real(getUserHome());
}
/**
+5 -4
View File
@@ -129,8 +129,7 @@ const updateHandler: HookHandler = {
/**
* Team course-correction keywords for the current project. The dispatcher has
* already chdir'd to the hook payload's cwd (hook-dispatch-cli), so
* autoDetectInit() resolves the right project, as it does for
* contributeHintAllowed. Only prompt hooks pay for the config read; an
* autoDetectInit() resolves the right project. Only prompt hooks pay for the config read; an
* unreadable config means "built-in keywords only".
*/
async function teamCorrectionKeywords(stdin: Record<string, unknown>): Promise<readonly string[]> {
@@ -250,8 +249,10 @@ export function buildVotesNudge(recalledDocIds: readonly string[]): string {
const contributeCheckHandler: HookHandler = {
name: 'contribute-check',
async execute(stdin, tool) {
// The payload's cwd, not the process's: hook-dispatch changes into it, but
// that can fail, and the gate must not then read the launcher's directory.
const { contributeHintAllowed } = await import('./skill-content.js');
if (!(await contributeHintAllowed())) return null;
if (!(await contributeHintAllowed(resolveHookCwd(stdin)))) return null;
const { contributeCheckForSession } = await import('./contribute-check.js');
const { formatStopHookOutput, relayWhenHidden } = await import('./utils/hook-output.js');
@@ -294,7 +295,7 @@ const pendingHintHandler: HookHandler = {
// feature off is not delivered later when it is turned back on.
const stashed = await pending.takePendingHint(sessionId);
const { contributeHintAllowed } = await import('./skill-content.js');
const hint = (await contributeHintAllowed()) ? stashed : null;
const hint = (await contributeHintAllowed(resolveHookCwd(stdin))) ? stashed : null;
const votesHint = await pending.takePendingVotesHint(sessionId);
// The votes nudge instructs the model; the contribute hint asks it to relay
+3 -3
View File
@@ -161,9 +161,9 @@ function firstLine(text: string): string {
*
* The reminder routes to `share`, so it is withheld wherever `shareGate`
* blocks it: a nudge there would send the agent to a command that says no.
* Both the hook dispatcher and the legacy `teamai contribute-check` ask this:
* the dispatcher has changed into the session's cwd already, the legacy
* command passes it, since its process may start anywhere.
* Both the hook dispatcher and the legacy `teamai contribute-check` ask this,
* passing the session's cwd: the process may run anywhere, and a `chdir` into
* the cwd can fail.
*/
export async function contributeHintAllowed(cwd?: string): Promise<boolean> {
const { isContributeHintEnabled } = await import('./types.js');