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: 3 additions & 1 deletion Sources/OrreryCore/Models/Tool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ public enum Tool: String, Codable, CaseIterable, Sendable {

/// The system default config directory (when not using Orrery).
public var defaultConfigDir: URL {
let home = FileManager.default.homeDirectoryForCurrentUser
// userHomeURL() is overridable (ORRERY_USER_HOME) so tests can isolate
// ~/.claude etc. from the developer's real home; unchanged in production.
let home = userHomeURL()
switch self {
case .claude: return home.appendingPathComponent(".claude")
case .codex: return home.appendingPathComponent(".codex")
Expand Down
19 changes: 17 additions & 2 deletions Sources/OrreryCore/Storage/OrreryHome.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import Foundation

/// The current user's home directory. Single source of truth for "the home dir"
/// used to build `~/.claude` etc., so tests can isolate those paths from the
/// developer's real home by setting `ORRERY_USER_HOME` (see `withIsolatedHome`).
///
/// A dedicated override — not `$HOME` — is used deliberately: setting the OS
/// `$HOME` would break macOS Keychain resolution (it locates the login keychain
/// via `$HOME`). In production `ORRERY_USER_HOME` is unset, so this is exactly
/// `homeDirectoryForCurrentUser` and behavior is unchanged.
public func userHomeURL() -> URL {
if let override = ProcessInfo.processInfo.environment["ORRERY_USER_HOME"],
!override.isEmpty {
return URL(fileURLWithPath: override)
}
return FileManager.default.homeDirectoryForCurrentUser
}

/// Resolves the orrery home directory (`$ORRERY_HOME`, else `~/.orrery`).
/// Single source of truth shared by EnvironmentStore and AccountStore.
public func orreryHomeURL() -> URL {
if let custom = ProcessInfo.processInfo.environment["ORRERY_HOME"] {
return URL(fileURLWithPath: custom)
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".orrery")
return userHomeURL().appendingPathComponent(".orrery")
}
12 changes: 12 additions & 0 deletions Tests/OrreryTests/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,26 @@ func withIsolatedHome(_ body: () throws -> Void) rethrows {

let savedHome = ProcessInfo.processInfo.environment["ORRERY_HOME"]
let savedActiveEnv = ProcessInfo.processInfo.environment["ORRERY_ACTIVE_ENV"]
// ORRERY_USER_HOME is redirected too: `Tool.defaultConfigDir` (and other
// home-relative paths) resolve via `userHomeURL()`, which honors it. Without
// this a test that triggers origin-takeover code would symlink/write into the
// developer's real ~/.claude even though ORRERY_HOME was isolated. We use a
// dedicated var, NOT $HOME — setting $HOME breaks macOS Keychain resolution.
let savedUserHome = ProcessInfo.processInfo.environment["ORRERY_USER_HOME"]
setenv("ORRERY_HOME", tmpDir.path, 1)
setenv("ORRERY_USER_HOME", tmpDir.path, 1)
unsetenv("ORRERY_ACTIVE_ENV")
defer {
if let savedHome {
setenv("ORRERY_HOME", savedHome, 1)
} else {
unsetenv("ORRERY_HOME")
}
if let savedUserHome {
setenv("ORRERY_USER_HOME", savedUserHome, 1)
} else {
unsetenv("ORRERY_USER_HOME")
}
if let savedActiveEnv {
setenv("ORRERY_ACTIVE_ENV", savedActiveEnv, 1)
} else {
Expand Down
30 changes: 30 additions & 0 deletions Tests/OrreryTests/TestIsolationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import Testing
@testable import OrreryCore

/// Guards against the test-isolation defect where the suite touched the real
/// ~/.claude: `Tool.defaultConfigDir` used `homeDirectoryForCurrentUser` (which
/// ignores $HOME), so origin-takeover code — which symlinks at
/// `tool.defaultConfigDir` — hijacked the developer's real ~/.claude even though
/// `withIsolatedHome` had redirected ORRERY_HOME.
@Suite("test isolation")
struct TestIsolationTests {

@Test("withIsolatedHome isolates tool.defaultConfigDir from the real home")
func isolatesDefaultConfigDir() {
// Captured OUTSIDE isolation: homeDirectoryForCurrentUser ignores $HOME,
// so this is always the developer's real home.
let realClaude = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claude").path

withIsolatedHome {
let isolated = Tool.claude.defaultConfigDir.path
#expect(isolated != realClaude,
"defaultConfigDir must NOT resolve to the real ~/.claude inside withIsolatedHome")

let tmpHome = ProcessInfo.processInfo.environment["ORRERY_HOME"] ?? ""
#expect(!tmpHome.isEmpty && isolated.hasPrefix(tmpHome),
"defaultConfigDir should resolve under the isolated temp home")
}
}
}
Loading