From 2f8e0377fffc0e22b95caf2fcc9d896d11971796 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:41:28 +0800 Subject: [PATCH 1/4] fix(cases): only refuse the cases folder and its direct children for a custom-folder case blockedReason() refused every path below a cases directory, with the reason that it is "already listed as a local case". GET /api/cases lists only the direct children of the cases directory, so only those can be registered twice. With CODEMAN_CASES_PATH set to a broad root such as /mnt/user/Scripts, the rule blocked almost the whole tree, including folders that are already linked cases. Refuse the cases directory itself and its direct children only, and name the cases directory in the message. --- .changeset/case-path-nested-in-cases-dir.md | 5 +++++ src/web/case-path.ts | 8 ++++++-- test/case-path.test.ts | 12 +++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .changeset/case-path-nested-in-cases-dir.md diff --git a/.changeset/case-path-nested-in-cases-dir.md b/.changeset/case-path-nested-in-cases-dir.md new file mode 100644 index 00000000..01e557d9 --- /dev/null +++ b/.changeset/case-path-nested-in-cases-dir.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Create New → custom folder no longer refuses every folder below the cases folder. Only the cases folder itself and its direct children are refused, because those are the only ones Codeman lists as local cases (a second registration would show them twice). With the cases folder set to a broad root such as `/mnt/user/Scripts` (`CODEMAN_CASES_PATH`), a folder like `/mnt/user/Scripts/GitHub/me/new-project` can now be created and linked. The refusal message now names the cases folder. diff --git a/src/web/case-path.ts b/src/web/case-path.ts index fb784412..c6401531 100644 --- a/src/web/case-path.ts +++ b/src/web/case-path.ts @@ -98,9 +98,13 @@ export function blockedReason( const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0]; if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case"; } + // Only the cases folder itself and its DIRECT children are listed as local cases (GET /api/cases reads + // that one level), so only those would be registered twice. A deeper folder is not listed and is a + // perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts, + // refusing everything below it blocked most of the tree. for (const dir of ctx.casesDirs ?? []) { - if (isWithin(absPath, dir)) { - return 'That folder is inside the cases folder; create a case there with plain Create New (no custom folder)'; + if (absPath === dir || dirname(absPath) === dir) { + return `That folder is in the cases folder (${dir}), where it is already listed as a case; create it there with plain Create New (no custom folder)`; } } return null; diff --git a/test/case-path.test.ts b/test/case-path.test.ts index 8984a14c..7b267dcd 100644 --- a/test/case-path.test.ts +++ b/test/case-path.test.ts @@ -54,14 +54,24 @@ describe('blockedReason', () => { expect(blockedReason('/usrlocal', c)).toBeNull(); }); - it('refuses a cases directory and anything inside it, when given', () => { + it('refuses a cases directory and its direct children, when given', () => { const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] }; expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/); expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/); + expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases'); expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull(); expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull(); }); + it('allows a folder deeper than a direct child of the cases directory (it is not listed as a local case)', () => { + // CODEMAN_CASES_PATH can be a broad root such as /mnt/user/Scripts; GET /api/cases lists only its + // direct children, so a folder further down can be linked without appearing twice. + const broad = { ...c, casesDirs: ['/mnt/user/Scripts'] }; + expect(blockedReason('/mnt/user/Scripts/GitHub', broad)).toMatch(/plain Create New/); + expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454', broad)).toBeNull(); + expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454/app', broad)).toBeNull(); + }); + it('judges against the system roots it is given', () => { expect(blockedReason('/private/etc/x', c)).toBeNull(); expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/); From 9722c5bafe596a87bd1c1a896b986c183e5fc167 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:05:39 +0200 Subject: [PATCH 2/4] fix(#567): document the cases-folder rule as the folder and its direct children docs/api-reference.md and the case-path.ts @fileoverview still said a custom folder must not be, or be inside, the cases directory. After #567 only the cases directory itself and its direct children are refused (those are what GET /api/cases lists as local cases); a folder further down is allowed. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/web/case-path.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0a785d11..6838cb8d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -776,7 +776,7 @@ The target is judged before anything is written: - It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise. - It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form, against both the given and the symlink-resolved roots. `400`. -- It must not be, or be inside, the cases directory (the caller's own and the shared one): a case there is a plain create without `path`. `400`. +- It must not be the cases directory (the caller's own or the shared one) or a direct child of it, since those are what `GET /api/cases` lists as local cases: a case there is a plain create without `path`. A folder further down is allowed. `400`. - Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`. A parent that does not answer (an unreachable network mount) or cannot be read is `422 OPERATION_FAILED`, checked through the bounded path probe before anything else touches it. - The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`. - `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case. diff --git a/src/web/case-path.ts b/src/web/case-path.ts index c6401531..df950396 100644 --- a/src/web/case-path.ts +++ b/src/web/case-path.ts @@ -12,8 +12,10 @@ * its symlink-resolved form, against both the given and the symlink-resolved roots (a home reached * through a link, macOS's `/etc` -> `/private/etc`), so a link into a blocked tree is not a way * around it; - * - it must not be, or be inside, a cases directory: a case there is a plain Create New, and the same - * folder listed both as a local case and as a linked one would make deleting it remove files; + * - it must not be a cases directory or a direct child of one: those are what `GET /api/cases` lists + * as local cases, so a case there is a plain Create New, and the same folder listed both as a local + * case and as a linked one would make deleting it remove files. A folder further down is not + * listed and is allowed; * - its parent must already exist (one folder is created, never a whole chain), and the folder * itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's * job, and silently scaffolding into someone's project is the one thing this must never do); From af52de4d05de8c4ca326fe83b392e2038e7584ba Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:05:39 +0200 Subject: [PATCH 3/4] fix(#567): separate refusal messages for the cases folder and a direct child The one message read "That folder is in the cases folder (...), where it is already listed as a case" for the cases folder itself too, which is not in itself and is never listed, and "already listed" is not true for a direct child that does not exist yet. Two messages now, both still pointing at plain Create New. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/case-path.ts | 7 +++++-- test/case-path.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/web/case-path.ts b/src/web/case-path.ts index df950396..369464d9 100644 --- a/src/web/case-path.ts +++ b/src/web/case-path.ts @@ -105,8 +105,11 @@ export function blockedReason( // perfectly good linked case: with CODEMAN_CASES_PATH pointing at a broad root such as /mnt/user/Scripts, // refusing everything below it blocked most of the tree. for (const dir of ctx.casesDirs ?? []) { - if (absPath === dir || dirname(absPath) === dir) { - return `That folder is in the cases folder (${dir}), where it is already listed as a case; create it there with plain Create New (no custom folder)`; + if (absPath === dir) { + return `That is the cases folder itself (${dir}); create a case inside it with plain Create New (no custom folder)`; + } + if (dirname(absPath) === dir) { + return `That folder is directly in the cases folder (${dir}), where it is listed as a local case; create it there with plain Create New (no custom folder)`; } } return null; diff --git a/test/case-path.test.ts b/test/case-path.test.ts index 7b267dcd..bd1d8be5 100644 --- a/test/case-path.test.ts +++ b/test/case-path.test.ts @@ -59,6 +59,11 @@ describe('blockedReason', () => { expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/); expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/); expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases'); + // Two messages: the cases folder is not "in" itself, and a child that does not exist yet is not + // "already" listed. + expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/cases folder itself/); + expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/directly in the cases folder/); + expect(blockedReason('/home/u/codeman-cases/foo', withCases)).not.toMatch(/already/); expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull(); expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull(); }); From 8dbcf6e28203e7a8e79085762828cac19d76b5f5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:05:39 +0200 Subject: [PATCH 4/4] fix(#567): route test for a custom folder two levels under the cases directory Pins the user-facing fix end to end: POST /api/cases with a path two levels down returns 200, lands in the linked-cases registry and is scaffolded. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/routes/case-custom-path-routes.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/routes/case-custom-path-routes.test.ts b/test/routes/case-custom-path-routes.test.ts index 9db507d1..6cb1b40d 100644 --- a/test/routes/case-custom-path-routes.test.ts +++ b/test/routes/case-custom-path-routes.test.ts @@ -183,6 +183,21 @@ describe('POST /api/cases with a custom path', () => { } }); + it('creates and links a folder two levels under the cases directory (not listed as a local case)', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + mkdirSync(join(CASES_DIR(), 'parent-dir'), { recursive: true }); + try { + const target = join(realpathSync(join(CASES_DIR(), 'parent-dir')), 'nested'); + const res = await create(app, { name: 'nested', path: target }); + expect(res.statusCode).toBe(200); + expect(res.json().data.case).toEqual({ name: 'nested', path: target }); + expect(linked()).toEqual({ nested: target }); + expect(existsSync(join(target, 'CLAUDE.md'))).toBe(true); + } finally { + safeRmHomeTree(join(CASES_DIR(), 'parent-dir')); + } + }); + it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => { process.env.CODEMAN_MULTIUSER = '1'; const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });