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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mancode",
"version": "0.6.1",
"version": "0.6.2",
"description": "AI coding agent workflow harness with mancode Continuity for cross-conversation tasks, decisions, verification, and team coordination.",
"type": "module",
"license": "AGPL-3.0-only",
Expand Down
35 changes: 28 additions & 7 deletions src/installers/v3-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
lstat,
mkdir,
readFile,
realpath,
rename,
rm,
rmdir,
Expand Down Expand Up @@ -2376,23 +2377,34 @@ async function assertAdapterPathSafe(
): Promise<void> {
const relative = path.relative(root, target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
throw new Error(
`MANCODE_ARTIFACT_PATH_UNSAFE: adapter target must stay inside the project root: ${target}`,
);
}
const rootEntry = await lstat(root);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) {
throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
throw new Error(
`MANCODE_ARTIFACT_PATH_UNSAFE: project root must be a real directory, not a symbolic link: ${root}`,
);
}
const segments = relative.split(path.sep);
let current = root;
for (let index = 0; index < segments.length; index += 1) {
current = path.join(current, segments[index] ?? '');
try {
const entry = await lstat(current);
if (
entry.isSymbolicLink() ||
(index < segments.length - 1 && !entry.isDirectory())
) {
throw new Error('MANCODE_ARTIFACT_PATH_UNSAFE');
if (entry.isSymbolicLink()) {
const detail = await describeAdapterSymlink(current);
const replacement =
index === segments.length - 1 ? 'a regular file' : 'a real directory';
throw new Error(
`MANCODE_ARTIFACT_PATH_UNSAFE: ${relative} is a symbolic link${detail}; mancode never writes through links. Replace it with ${replacement} before initializing the adapter.`,
);
}
if (index < segments.length - 1 && !entry.isDirectory()) {
throw new Error(
`MANCODE_ARTIFACT_PATH_UNSAFE: ${relative} cannot be used because ${segments[index]} is not a directory`,
);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') return;
Expand All @@ -2401,6 +2413,15 @@ async function assertAdapterPathSafe(
}
}

async function describeAdapterSymlink(linkPath: string): Promise<string> {
try {
const resolved = await realpath(linkPath);
return ` (resolves to ${resolved})`;
} catch {
return ' (broken link)';
}
}

async function removeManagedV3Block(
filePath: string,
startMarker: string,
Expand Down
21 changes: 21 additions & 0 deletions tests/v3-adapter-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,27 @@ describe('V3 adapter bootstrap integration', () => {
},
);

it.skipIf(process.platform === 'win32')(
'names an in-repo symlinked fixed target instead of a bare path error',
async () => {
await init(root, { v3: true, platform: 'codex' });
// Repo convention (e.g. openai/codex): CLAUDE.md mirrors AGENTS.md.
await writeFile(
path.join(root, 'AGENTS.md'),
'# shared agent instructions\n',
);
await symlink('AGENTS.md', path.join(root, 'CLAUDE.md'));

await expect(installV3Adapter(root, 'claude-code')).rejects.toThrow(
/MANCODE_ARTIFACT_PATH_UNSAFE: CLAUDE\.md is a symbolic link \(resolves to .*AGENTS\.md\)/,
);
// The link itself is left untouched: mancode never writes through it.
await expect(
readFile(path.join(root, 'CLAUDE.md'), 'utf8'),
).resolves.toBe('# shared agent instructions\n');
},
);

it('retires legacy managed entrypoints when repairing an active V3 adapter', async () => {
await init(root, { v3: true });
const legacyCodexAlias = path.join(
Expand Down
Loading