mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user