diff --git a/src/features/team-mode/team-registry/paths.test.ts b/src/features/team-mode/team-registry/paths.test.ts index 1fd80f4bb..2cf1cc575 100644 --- a/src/features/team-mode/team-registry/paths.test.ts +++ b/src/features/team-mode/team-registry/paths.test.ts @@ -117,4 +117,53 @@ describe("paths", () => { expect(directoryStat.mode & 0o777).toBe(0o700) } }) + + test("ensureBaseDirs swallows EPERM from chmod and logs a warning instead of aborting team-mode init", async () => { + // given: directories exist with permissive mode that chmod cannot tighten + // (mirrors macOS network mount / non-owner / SIP cases reported in #4023). + const baseDir = path.join(tmpdir(), `omo-test-eperm-${randomUUID()}`) + temporaryDirectories.push(baseDir) + await mkdir(baseDir, { recursive: true }) + await mkdir(path.join(baseDir, "teams"), { recursive: true }) + await mkdir(path.join(baseDir, "runtime"), { recursive: true }) + await mkdir(path.join(baseDir, "worktrees"), { recursive: true }) + + const realFs = await import("node:fs/promises") + let chmodCalls = 0 + mock.module("node:fs/promises", () => ({ + ...realFs, + chmod: async (target: string) => { + chmodCalls += 1 + const eperm = Object.assign(new Error(`EPERM: operation not permitted, chmod '${target}'`), { + code: "EPERM", + syscall: "chmod", + path: target, + errno: -1, + }) + throw eperm + }, + })) + + const { ensureBaseDirs: ensureBaseDirsWithMockedChmod } = await import("./paths") + logCalls.splice(0) + + // when + let thrown: unknown = null + try { + await ensureBaseDirsWithMockedChmod(baseDir) + } catch (error) { + thrown = error + } + + // then: function does not throw, EPERM was reached, and one warning was logged. + expect(thrown).toBeNull() + expect(chmodCalls).toBeGreaterThan(0) + const warnings = logCalls.filter(([message]) => + message === "team-mode: chmod refused on base directory; continuing with existing permissions" + ) + expect(warnings.length).toBeGreaterThan(0) + const firstWarning = warnings[0]?.[1] as { code?: string; path?: string } | undefined + expect(firstWarning?.code).toBe("EPERM") + expect(firstWarning?.path).toContain(baseDir) + }) }) diff --git a/src/features/team-mode/team-registry/paths.ts b/src/features/team-mode/team-registry/paths.ts index c80032575..b41b1ed7e 100644 --- a/src/features/team-mode/team-registry/paths.ts +++ b/src/features/team-mode/team-registry/paths.ts @@ -100,6 +100,23 @@ export async function discoverTeamSpecs( return discoveredTeamSpecs } +async function safeChmod(directoryPath: string, mode: number): Promise { + try { + await chmod(directoryPath, mode) + } catch (error) { + const errnoError = error as NodeJS.ErrnoException + if (errnoError?.code === "EPERM" || errnoError?.code === "ENOTSUP" || errnoError?.code === "EINVAL") { + log("team-mode: chmod refused on base directory; continuing with existing permissions", { + path: directoryPath, + code: errnoError.code, + syscall: errnoError.syscall, + }) + return + } + throw error + } +} + export async function ensureBaseDirs(baseDir: string): Promise { const directories = [ baseDir, @@ -110,13 +127,13 @@ export async function ensureBaseDirs(baseDir: string): Promise { for (const directoryPath of directories) { await mkdir(directoryPath, { recursive: true, mode: 0o700 }) - await chmod(directoryPath, 0o700) + await safeChmod(directoryPath, 0o700) } await Promise.all(directories.map(async (directoryPath) => { const directoryStat = await stat(directoryPath) if ((directoryStat.mode & 0o777) !== 0o700) { - await chmod(directoryPath, 0o700) + await safeChmod(directoryPath, 0o700) } })) }