From bfd52848f9c02add53ab87e4a7dff31aae12b745 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 13:12:03 +0000 Subject: [PATCH] test: skip symlink fixtures where the host cannot create symlinks On Windows without Developer Mode, seven tests failed with EPERM while creating symlinks during fixture setup. Guard them with the existing symlinksSupported probe, matching the other symlink tests. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 98 +++++----- apps/server/src/serverSettings.test.ts | 39 ++-- .../src/server/clientPolicy.test.ts | 181 +++++++++--------- .../src/server/usage.test.ts | 106 +++++----- 4 files changed, 220 insertions(+), 204 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index baf0f27f9edb3..16c8aff63ff03 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -330,36 +330,38 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("returns the imported project ID through a realpath alias", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const workspaceAlias = path.join(linkParent, "workspace-alias"); - yield* fileSystem.symlink(workspace, workspaceAlias); + it.effect.skipIf(!symlinksSupported)( + "returns the imported project ID through a realpath alias", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(workspaceAlias), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - importedWorkspaceRoots: [workspace], - }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); - expect(result.candidates[0]).toMatchObject({ - path: workspace, - projectId: ProjectId.make("project-1"), - alreadyImported: true, - git: null, - }); - }), + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), ); it.effect("merges case aliases and preserves the persisted project path", () => @@ -873,29 +875,31 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const fileSystem = yield* FileSystem.FileSystem; + it.effect.skipIf(!symlinksSupported)( + "excludes sandboxes reached through a symlink into the worktrees dir", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; - const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); - yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); - const symlinkCwd = path.join(linkParent, "innocent-project"); - yield* fileSystem.symlink(worktreeCwd, symlinkCwd); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(symlinkCwd), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); - const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); - expect(result.candidates).toEqual([]); - }), + expect(result.candidates).toEqual([]); + }), ); it.effect("finds the cwd on a later line when the first records carry none", () => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bd985cab203ac..943a1d69a29b1 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -11,6 +11,7 @@ import { UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -235,26 +236,28 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("saves through a symlinked settings file without replacing the link", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const linkedSettingsPath = path.join(dotfiles, "settings.json"); - yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(linkedSettingsPath, config.settingsPath); + it.effect.skipIf(!symlinksSupported)( + "saves through a symlinked settings file without replacing the link", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* ServerSettingsModule.ServerSettingsService; + const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); + const linkedSettingsPath = path.join(dotfiles, "settings.json"); + yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); + yield* fs.remove(config.settingsPath, { force: true }); + yield* fs.symlink(linkedSettingsPath, config.settingsPath); - yield* service.updateSettings({ responseStreamingMode: "paragraph" }); + yield* service.updateSettings({ responseStreamingMode: "paragraph" }); - assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath); - const persisted = yield* decodeServerSettingsJson( - yield* fs.readFileString(linkedSettingsPath), - ); - assert.equal(persisted.responseStreamingMode, "paragraph"); - }).pipe(Effect.provide(layerServerSettings())), + assert.equal(yield* fs.readLink(config.settingsPath), linkedSettingsPath); + const persisted = yield* decodeServerSettingsJson( + yield* fs.readFileString(linkedSettingsPath), + ); + assert.equal(persisted.responseStreamingMode, "paragraph"); + }).pipe(Effect.provide(layerServerSettings())), ); it.effect("retries a failed settings read instead of keeping the failure", () => diff --git a/packages/provider-acp/src/server/clientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts index 630ebc979e753..18d2c5f29af59 100644 --- a/packages/provider-acp/src/server/clientPolicy.test.ts +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -122,100 +123,104 @@ describe("acpPermissionDisposition", () => { } }); - it.effect("denies mutations through workspace symlinks that escape the writable roots", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-workspace-", - }); - const outside = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-outside-", - }); - const outsideFile = path.join(outside, "existing.ts"); - yield* fileSystem.writeFileString(outsideFile, "outside"); - yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts")); - yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory")); + it.effect.skipIf(!symlinksSupported)( + "denies mutations through workspace symlinks that escape the writable roots", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-workspace-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-outside-", + }); + const outsideFile = path.join(outside, "existing.ts"); + yield* fileSystem.writeFileString(outsideFile, "outside"); + yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts")); + yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory")); - const realPolicy: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd: workspace, - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: [], - networkAccess: false, - }, - }; + const realPolicy: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd: workspace, + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: [], + networkAccess: false, + }, + }; - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "linked-file.ts" }]), - ), - "deny", - ); - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]), - ), - "deny", - "a missing leaf below an escaping directory symlink must not be auto-approved", - ); - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]), - ), - "deny", - "physical symlink traversal must be resolved before parent segments", - ); - }).pipe(Effect.provide(NodeServices.layer)), + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "linked-file.ts" }]), + ), + "deny", + ); + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]), + ), + "deny", + "a missing leaf below an escaping directory symlink must not be auto-approved", + ); + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]), + ), + "deny", + "physical symlink traversal must be resolved before parent segments", + ); + }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("allows existing and new files beneath canonical writable roots", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-workspace-", - }); - const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-acp-permission-link-parent-", - }); - const workspaceLink = path.join(workspaceLinkParent, "workspace-link"); - yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true }); - yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing"); - yield* fileSystem.symlink(workspace, workspaceLink); + it.effect.skipIf(!symlinksSupported)( + "allows existing and new files beneath canonical writable roots", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-workspace-", + }); + const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-permission-link-parent-", + }); + const workspaceLink = path.join(workspaceLinkParent, "workspace-link"); + yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing"); + yield* fileSystem.symlink(workspace, workspaceLink); - const realPolicy: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd: workspaceLink, - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - writableRoots: [], - networkAccess: false, - }, - }; + const realPolicy: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd: workspaceLink, + approvalPolicy: "never", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: [], + networkAccess: false, + }, + }; - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "src/existing.ts" }]), - ), - "allow", - ); - assert.equal( - acpPermissionDisposition( - realPolicy, - permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]), - ), - "allow", - "non-existent descendants of a real in-root ancestor remain writable", - ); - }).pipe(Effect.provide(NodeServices.layer)), + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "src/existing.ts" }]), + ), + "allow", + ); + assert.equal( + acpPermissionDisposition( + realPolicy, + permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]), + ), + "allow", + "non-existent descendants of a real in-root ancestor remain writable", + ); + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index 20a768609a7da..7cf0e4e30d1bf 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -180,7 +181,7 @@ describe("readOpenCodeUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("never follows symlinks in the legacy OpenCode store", () => + it.effect.skipIf(!symlinksSupported)("never follows symlinks in the legacy OpenCode store", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -236,56 +237,59 @@ describe("readOpenCodeUsage", () => { import * as NodeSqlite from "node:sqlite"; describe("openCodeUsageReader", () => { - it.effect("scans canonical instance roots once, including XDG and home defaults", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temp = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-instances-test-" }); - const home = yield* fileSystem.realPath(temp); - const defaultRoot = path.join(home, ".local", "share", "opencode"); - const instanceRoot = path.join(home, "instance"); - const dataHome = path.join(home, "xdg"); - const xdgRoot = path.join(dataHome, "opencode"); - const alias = path.join(home, "default-alias"); - for (const root of [defaultRoot, instanceRoot, xdgRoot]) { - yield* fileSystem.makeDirectory(root, { recursive: true }); - } - yield* fileSystem.symlink(defaultRoot, alias); - const instances = [ - { id: "opencode", environment: {}, configured: false }, - { - id: "opencode-list", - environment: { OPENCODE_DATA_DIR: ` ${instanceRoot}, ${alias}, ${defaultRoot}, , ` }, - configured: true, - }, - { - id: "opencode-xdg", - environment: { XDG_DATA_HOME: dataHome }, - configured: true, - }, - ]; - if (openCodeUsageReader.kind !== "scan") throw new Error("Expected an OpenCode scan reader"); - const sources = yield* openCodeUsageReader - .scan({ - instances: instances.map(({ id, environment, configured }) => ({ - instanceId: ProviderInstanceId.make(id), - config: undefined, - environment, - configured, - })), - settings: DEFAULT_SERVER_SETTINGS, - windowStartMs: 0, - retentionCutoffMs: 0, - awaitRefresh: true, - }) - .pipe( - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(HostProcess.HomeDirectory, home), + it.effect.skipIf(!symlinksSupported)( + "scans canonical instance roots once, including XDG and home defaults", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temp = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-instances-test-" }); + const home = yield* fileSystem.realPath(temp); + const defaultRoot = path.join(home, ".local", "share", "opencode"); + const instanceRoot = path.join(home, "instance"); + const dataHome = path.join(home, "xdg"); + const xdgRoot = path.join(dataHome, "opencode"); + const alias = path.join(home, "default-alias"); + for (const root of [defaultRoot, instanceRoot, xdgRoot]) { + yield* fileSystem.makeDirectory(root, { recursive: true }); + } + yield* fileSystem.symlink(defaultRoot, alias); + const instances = [ + { id: "opencode", environment: {}, configured: false }, + { + id: "opencode-list", + environment: { OPENCODE_DATA_DIR: ` ${instanceRoot}, ${alias}, ${defaultRoot}, , ` }, + configured: true, + }, + { + id: "opencode-xdg", + environment: { XDG_DATA_HOME: dataHome }, + configured: true, + }, + ]; + if (openCodeUsageReader.kind !== "scan") + throw new Error("Expected an OpenCode scan reader"); + const sources = yield* openCodeUsageReader + .scan({ + instances: instances.map(({ id, environment, configured }) => ({ + instanceId: ProviderInstanceId.make(id), + config: undefined, + environment, + configured, + })), + settings: DEFAULT_SERVER_SETTINGS, + windowStartMs: 0, + retentionCutoffMs: 0, + awaitRefresh: true, + }) + .pipe( + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.HomeDirectory, home), + ); + assert.deepStrictEqual( + sources.map((source) => source.dir).sort(), + [defaultRoot, instanceRoot, xdgRoot].sort(), ); - assert.deepStrictEqual( - sources.map((source) => source.dir).sort(), - [defaultRoot, instanceRoot, xdgRoot].sort(), - ); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ); });