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
25 changes: 25 additions & 0 deletions Sources/OrreryCore/Setup/KeychainAccess.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// Injectable seam over the macOS Keychain so origin-account seeding is
/// unit-testable without touching the real login keychain (which cannot be
/// isolated in tests — setting $HOME breaks keychain resolution).
public struct KeychainAccess: Sendable {
/// True if a keychain generic-password item exists for `service`.
public var itemExists: @Sendable (_ service: String) -> Bool
/// Copy the item at `from` service to `to` service; returns success.
public var copyItem: @Sendable (_ from: String, _ to: String) -> Bool

public init(
itemExists: @escaping @Sendable (_ service: String) -> Bool,
copyItem: @escaping @Sendable (_ from: String, _ to: String) -> Bool
) {
self.itemExists = itemExists
self.copyItem = copyItem
}

/// Production wiring — the real Keychain.
public static let live = KeychainAccess(
itemExists: ClaudeKeychain.keychainItemExists,
copyItem: ClaudeKeychain.copyKeychainItem
)
}
84 changes: 84 additions & 0 deletions Sources/OrreryCore/Setup/OriginAccountSeeder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

/// Fresh-user onboarding: after origin takeover moved `~/.<tool>` into the origin
/// workspace, create a brand-new "origin" account per tool that captures the
/// existing login and pins to the origin workspace — so a normal user's account
/// holds only its credential/identity, never the shared data.
///
/// Idempotent + best-effort. Runs for a tool only when it has NO origin account
/// yet (leaving existing installs untouched) and its origin workspace holds a
/// capturable login. A per-tool failure warns and never blocks startup.
public enum OriginAccountSeeder {

public static func seedOriginAccountsIfNeeded(keychain: KeychainAccess = .live) {
let acctStore = AccountStore.default
let envStore = EnvironmentStore.default
var origin = envStore.loadOriginWorkspace()

for tool in Tool.allCases {
guard origin.account(for: tool) == nil else { continue } // existing → untouched
let wsToolDir = envStore.originConfigDir(tool: tool)
guard hasCapturableLogin(tool: tool, workspaceToolDir: wsToolDir, keychain: keychain)
else { continue }

do {
let id = UUID().uuidString
let account = Account(
id: id, tool: tool, displayName: "origin",
keychainItem: tool == .claude
? ClaudeKeychain.serviceName(forOrreryAccount: id) : nil,
workspace: Workspace.reservedOriginName)
try acctStore.save(account)

try captureLogin(account: account, workspaceToolDir: wsToolDir, keychain: keychain)

origin.setAccount(id, for: tool)
try envStore.saveOriginWorkspace(origin)

if tool == .claude {
try ClaudeAccountMigration.migrateAccount(
account, accountStore: acctStore, environmentStore: envStore)
}
} catch {
FileHandle.standardError.write(Data(
"orrery: could not seed origin \(tool.rawValue) account: \(error)\n".utf8))
}
}
}

private static func hasCapturableLogin(
tool: Tool, workspaceToolDir: URL, keychain: KeychainAccess
) -> Bool {
switch tool {
case .codex, .gemini:
let f = workspaceToolDir.appendingPathComponent(
FilesystemCredentialAdapter.credentialFileName(for: tool))
return FileManager.default.fileExists(atPath: f.path)
case .claude:
#if os(macOS)
return keychain.itemExists(ClaudeKeychain.service(for: nil))
#else
return FileManager.default.fileExists(
atPath: workspaceToolDir.appendingPathComponent(".credentials.json").path)
#endif
}
}

private static func captureLogin(
account: Account, workspaceToolDir: URL, keychain: KeychainAccess
) throws {
switch account.tool {
case .codex, .gemini:
try AccountLoginFlow.importFrom(stagingDir: workspaceToolDir, into: account)
case .claude:
#if os(macOS)
guard let dst = account.keychainItem,
keychain.copyItem(ClaudeKeychain.service(for: nil), dst) else {
throw AccountLoginFlow.LoginError.credentialNotProduced(.claude)
}
#else
try AccountLoginFlow.importFrom(stagingDir: workspaceToolDir, into: account)
#endif
}
}
}
7 changes: 7 additions & 0 deletions Sources/orrery/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ private func runOrreryMain() async throws {
// dir and point ~/.claude at the origin account dir, so origin reads the same
// account dir that `orrery use` selects (statusline + settings consistent).
AccountMigration.runAccountConfigConsolidationIfNeeded(homeURL: orreryHomeURL())
// Fresh-user onboarding: after takeover moved ~/.<tool> into the origin
// workspace, create a link-only origin account per tool that captures the
// existing login (no-op once an origin account exists → existing installs
// untouched). Must run BEFORE enforceOriginClaudeDir so the claude pin exists
// for the ~/.claude repoint below.
OriginAccountSeeder.seedOriginAccountsIfNeeded()

// Ongoing invariant (every run, not flag-guarded): keep origin pinned and
// ~/.claude pointing at the origin account dir. A ~/.claude still on the old
// workspace target (upgrades / 3.0.4-damaged installs) self-heals here on the
Expand Down
139 changes: 139 additions & 0 deletions Tests/OrreryTests/OriginAccountSeederTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import Foundation
import Testing
@testable import OrreryCore

@Suite("OriginAccountSeeder")
struct OriginAccountSeederTests {

/// A fake keychain that reports NO claude login. Every test injects a fake so
/// the seeder's claude branch never touches the real macOS login keychain
/// (which is global — not isolated by ORRERY_HOME — so `.live` in a test
/// would create stray `Claude Code-orrery-*` items in the developer's keychain).
private let noClaudeLogin = KeychainAccess(
itemExists: { _ in false }, copyItem: { _, _ in false })

/// Simulate post-takeover state: a credential file sitting in the origin
/// workspace's <tool> dir, and no origin account for that tool yet.
private func seedWorkspaceCredential(tool: Tool, fileName: String, contents: String) throws {
let ws = EnvironmentStore.default.originConfigDir(tool: tool) // workspaces/origin/<tool>
try FileManager.default.createDirectory(at: ws, withIntermediateDirectories: true)
try Data(contents.utf8).write(to: ws.appendingPathComponent(fileName))
}

@Test("creates a codex origin account capturing auth.json from the workspace")
func seedsCodex() throws {
try withIsolatedHome {
try seedWorkspaceCredential(tool: .codex, fileName: "auth.json", contents: #"{"OPENAI_API_KEY":"x"}"#)

OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: noClaudeLogin)

let acctStore = AccountStore.default
let acct = try #require(try acctStore.findByDisplayName("origin", tool: .codex))
#expect(FileManager.default.fileExists(
atPath: acctStore.accountDir(id: acct.id, tool: .codex)
.appendingPathComponent("auth.json").path))
#expect(EnvironmentStore.default.loadOriginWorkspace().account(for: .codex) == acct.id)
}
}

@Test("creates a gemini origin account capturing oauth_creds.json")
func seedsGemini() throws {
try withIsolatedHome {
try seedWorkspaceCredential(tool: .gemini, fileName: "oauth_creds.json", contents: #"{"access_token":"x"}"#)

OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: noClaudeLogin)

let acctStore = AccountStore.default
let acct = try #require(try acctStore.findByDisplayName("origin", tool: .gemini))
#expect(FileManager.default.fileExists(
atPath: acctStore.accountDir(id: acct.id, tool: .gemini)
.appendingPathComponent("oauth_creds.json").path))
#expect(EnvironmentStore.default.loadOriginWorkspace().account(for: .gemini) == acct.id)
}
}

@Test("creates a claude origin account: pinned, link-only; keychain copied with correct services")
func seedsClaude() throws {
try withIsolatedHome {
let envStore = EnvironmentStore.default
let acctStore = AccountStore.default
// Post-takeover: origin workspace claude dir exists (with a shared dir to mirror).
let wsClaude = envStore.originConfigDir(tool: .claude) // workspaces/origin/claude
try FileManager.default.createDirectory(
at: wsClaude.appendingPathComponent("plugins"), withIntermediateDirectories: true)

// Recording fake: pretend the default login exists; capture copy calls.
// Reference box so the @Sendable closure can record without a mutable capture.
final class Rec: @unchecked Sendable { var calls: [(from: String, to: String)] = [] }
let rec = Rec()
let fake = KeychainAccess(
itemExists: { _ in true },
copyItem: { from, to in rec.calls.append((from, to)); return true })

OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: fake)

let acct = try #require(try acctStore.findByDisplayName("origin", tool: .claude))
// pinned to origin
#expect(envStore.loadOriginWorkspace().account(for: .claude) == acct.id)
// migrateAccount ran: account mirrors the workspace (plugins is a symlink)
let acctDir = acctStore.accountDir(id: acct.id, tool: .claude)
#expect((try? FileManager.default.destinationOfSymbolicLink(
atPath: acctDir.appendingPathComponent("plugins").path))
== wsClaude.appendingPathComponent("plugins").path)
// keychain copied from the default service to the per-account service
#expect(rec.calls.count == 1)
#expect(rec.calls.first?.from == ClaudeKeychain.service(for: nil)) // "Claude Code-credentials"
#expect(rec.calls.first?.to == ClaudeKeychain.serviceName(forOrreryAccount: acct.id))
}
}

@Test("no capturable login → no account created")
func skipsWhenNoLogin() throws {
try withIsolatedHome {
OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: noClaudeLogin)
let codexAcct = try AccountStore.default.findByDisplayName("origin", tool: .codex)
let claudeAcct = try AccountStore.default.findByDisplayName("origin", tool: .claude)
#expect(codexAcct == nil)
#expect(claudeAcct == nil)
}
}

@Test("existing origin account → no-op (idempotent, existing installs untouched)")
func skipsWhenOriginAccountExists() throws {
try withIsolatedHome {
let envStore = EnvironmentStore.default
let acctStore = AccountStore.default
// Pre-existing origin codex account + pin.
let existing = Account(tool: .codex, displayName: "origin")
try acctStore.save(existing)
var origin = envStore.loadOriginWorkspace()
origin.setAccount(existing.id, for: .codex)
try envStore.saveOriginWorkspace(origin)
// A workspace credential is present, but the pin already exists.
let ws = envStore.originConfigDir(tool: .codex)
try FileManager.default.createDirectory(at: ws, withIntermediateDirectories: true)
try Data("x".utf8).write(to: ws.appendingPathComponent("auth.json"))

OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: noClaudeLogin)

let count = try acctStore.list(tool: .codex).count
#expect(count == 1)
#expect(envStore.loadOriginWorkspace().account(for: .codex) == existing.id)
}
}

@Test("running twice creates the account only once")
func idempotentAcrossRuns() throws {
try withIsolatedHome {
let ws = EnvironmentStore.default.originConfigDir(tool: .codex)
try FileManager.default.createDirectory(at: ws, withIntermediateDirectories: true)
try Data("x".utf8).write(to: ws.appendingPathComponent("auth.json"))

OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: noClaudeLogin)
OriginAccountSeeder.seedOriginAccountsIfNeeded(keychain: noClaudeLogin)

let count = try AccountStore.default.list(tool: .codex).count
#expect(count == 1)
}
}
}
Loading
Loading