test: skip symlink fixtures where the host cannot create symlinks#9566
2 unresolved threads ยท Jump to file
- apps/server/src/provider/Drivers/CodexHomeLayout.test.ts:140 ยท ### Shadow home tests still need symlinks **Medium Severity** <!-- DESCRIPTION START --> Only the
- packages/shared/src/testing/symlinks.ts:21โ21 ยท <!-- MURMUR_IGNORE --> ๐ก **Medium** `testing/symlinks.ts:21` A cleanup error from `rmSync` escapes
apps/server/src/provider/Drivers/CodexHomeLayout.test.ts ยท Earlier revision 41b026ab ยท right 140
Original diff
@@ -84,54 +85,59 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => {
});
describe("materializeCodexShadowHome", () => {
- it.effect("materializes a shadow home with shared state links and private auth", () =>
- Effect.gen(function* () {
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const sharedHome = yield* makeTempDir("t3code-codex-shared-");
- const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
- const shadowHome = path.join(shadowRoot, "shadow");
-
- yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions"));
- yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
- yield* writeTextFile(path.join(sharedHome, "models_cache.json"), '{"models":["shared"]}\n');
- yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');
- yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
- yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
- yield* fileSystem.symlink(
- path.join(sharedHome, "models_cache.json"),
- path.join(shadowHome, "models_cache.json"),
- );
-
- const layout = yield* resolveCodexHomeLayout(
- decodeCodexSettings({
- homePath: sharedHome,
- shadowHomePath: shadowHome,
- }),
- );
-
- yield* materializeCodexShadowHome(layout);
-
- const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions"));
- const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
- const mcpOauthLocksTarget = yield* fileSystem.readLink(
- path.join(shadowHome, "mcp-oauth-locks"),
- );
- const modelsCacheExists = yield* fileSystem.exists(
- path.join(shadowHome, "models_cache.json"),
- );
- const authLinkResult = yield* fileSystem
- .readLink(path.join(shadowHome, "auth.json"))
- .pipe(Effect.result);
- const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));
-
- expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));
- expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
- expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks"));
- expect(modelsCacheExists).toBe(false);
- expect(authLinkResult._tag).toBe("Failure");
- expect(authContents).toContain("shadow");
- }),
+ it.effect.skipIf(!symlinksSupported)(
+ "materializes a shadow home with shared state links and private auth",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const sharedHome = yield* makeTempDir("t3code-codex-shared-");
+ const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-");
+ const shadowHome = path.join(shadowRoot, "shadow");
+
+ yield* fileSystem.makeDirectory(path.join(sharedHome, "sessions"));
+ yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n');
+ yield* writeTextFile(
+ path.join(sharedHome, "models_cache.json"),
+ '{"models":["shared"]}\n',
+ );
+ yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n');
+ yield* fileSystem.makeDirectory(shadowHome, { recursive: true });
+ yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n');
+ yield* fileSystem.symlink(
+ path.join(sharedHome, "models_cache.json"),
+ path.join(shadowHome, "models_cache.json"),
+ );
+
+ const layout = yield* resolveCodexHomeLayout(
+ decodeCodexSettings({
+ homePath: sharedHome,
+ shadowHomePath: shadowHome,
+ }),
+ );
+
+ yield* materializeCodexShadowHome(layout);
+
+ const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions"));
+ const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml"));
+ const mcpOauthLocksTarget = yield* fileSystem.readLink(
+ path.join(shadowHome, "mcp-oauth-locks"),
+ );
+ const modelsCacheExists = yield* fileSystem.exists(
+ path.join(shadowHome, "models_cache.json"),
+ );
+ const authLinkResult = yield* fileSystem
+ .readLink(path.join(shadowHome, "auth.json"))
+ .pipe(Effect.result);
+ const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json"));
+
+ expect(sessionsTarget).toBe(path.join(sharedHome, "sessions"));
+ expect(configTarget).toBe(path.join(sharedHome, "config.toml"));
+ expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks"));
+ expect(modelsCacheExists).toBe(false);
+ expect(authLinkResult._tag).toBe("Failure");
+ expect(authContents).toContain("shadow");
+ }),Shadow home tests still need symlinks
Medium Severity
Only the fixture-planting materializeCodexShadowHome case is gated on symlinksSupported. Sibling tests still call materializeCodexShadowHome, which creates file and directory links via ensureSymlink. On hosts that cannot create them those cases fail at setup or with CodexShadowHomeFileSystemError instead of the expected conflict, so the Windows skip does not cover this file.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 41b026ab4328737b724ffaa40b10e644195c423e. Configure here.
packages/shared/src/testing/symlinks.ts ยท Earlier revision 13f4759d ยท right 21โ21
Original diff
@@ -0,0 +1,23 @@
+// @effect-diagnostics nodeBuiltinImport:off - one synchronous probe at module load, outside any Effect runtime.
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+
+/**
+ * Whether this host lets an unprivileged process create symlinks. Windows
+ * refuses without Developer Mode or elevation, so tests that plant a symlink
+ * to prove containment or escape detection should `skipIf(!symlinksSupported)`
+ * rather than fail at setup. Probed once per process.
+ */
+export const symlinksSupported: boolean = (() => {
+ let directory: string | undefined;
+ try {
+ directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-symlink-probe-"));
+ NodeFS.symlinkSync(NodePath.join(directory, "target"), NodePath.join(directory, "link"));
+ return true;
+ } catch {
+ return false;
+ } finally {
+ if (directory !== undefined) NodeFS.rmSync(directory, { recursive: true, force: true });packages/shared/src/testing/symlinks.ts ยท right 21โ21
๐ก Medium testing/symlinks.ts:21
A cleanup error from rmSync escapes the probe and makes importing this helper throw instead of returning false or true, so every importing test fails during module initialization when Windows temporarily reports EPERM or EBUSY. Catch cleanup failures so they cannot override the probe result.
No longer relevant as of 8b79a693fbb1e76f4ffa6256b03e21162dabedf1
Good call โ a failed cleanup shouldn't decide the probe. Caught and ignored now.
I still see rmSync called without a nested try/catch, so a cleanup error can still escape the probe.