diff --git a/apps/Apple/App/MainHostCredentialStore.swift b/apps/Apple/App/MainHostCredentialStore.swift index f3e7d0c..488a070 100644 --- a/apps/Apple/App/MainHostCredentialStore.swift +++ b/apps/Apple/App/MainHostCredentialStore.swift @@ -1,9 +1,10 @@ import Foundation import Security +/// Stores the main host's refresh token. The password is never persisted. struct MainHostCredentialStore { private static let service = "com.leask.tabminal.mobile.main-host" - private static let account = "auth-token" + private static let account = "refresh-token" func loadToken() -> String? { let query: [CFString: Any] = [ diff --git a/apps/Apple/App/MobileAppModel.swift b/apps/Apple/App/MobileAppModel.swift index aee0277..d190d8b 100644 --- a/apps/Apple/App/MobileAppModel.swift +++ b/apps/Apple/App/MobileAppModel.swift @@ -131,7 +131,11 @@ final class MobileAppModel { @ObservationIgnored private var heartbeatTask: Task? @ObservationIgnored - private var mainToken: String = "" + private var mainAccessToken: String = "" + @ObservationIgnored + private var mainRefreshToken: String = "" + @ObservationIgnored + private var mainAccessTokenExpiresAt: Date? @ObservationIgnored private var workspaces: [String: SessionWorkspaceModel] = [:] @ObservationIgnored @@ -228,24 +232,15 @@ final class MobileAppModel { let trimmedPassword = mainPassword.trimmingCharacters( in: .whitespacesAndNewlines ) - let token = !trimmedPassword.isEmpty - ? TabminalPasswordHasher.sha256Hex(trimmedPassword) - : credentialStore.loadToken() ?? "" + let storedRefreshToken = credentialStore.loadToken() ?? "" - guard !token.isEmpty else { + guard !trimmedPassword.isEmpty || !storedRefreshToken.isEmpty else { loginErrorMessage = "Password is required." return } loginErrorMessage = "" isAuthenticating = true - let mainEndpoint = TabminalServerEndpoint( - id: "main", - baseURL: parsedURL, - host: mainHostName, - token: token, - isPrimary: true - ) Task { defer { @@ -254,7 +249,21 @@ final class MobileAppModel { do { phase = .loading - mainToken = token + let tokens = try await authenticate( + baseURL: parsedURL, + host: mainHostName, + password: trimmedPassword, + refreshToken: storedRefreshToken + ) + applyMainTokens(tokens) + + let mainEndpoint = TabminalServerEndpoint( + id: "main", + baseURL: parsedURL, + host: mainHostName, + token: tokens.accessToken, + isPrimary: true + ) try await bootstrap(mainEndpoint: mainEndpoint) defaults.set( mainEndpoint.baseURL.absoluteString, @@ -264,7 +273,7 @@ final class MobileAppModel { mainEndpoint.host, forKey: Self.defaultsMainHostKey ) - credentialStore.saveToken(token) + mainPassword = "" phase = .ready } catch { phase = .login @@ -273,6 +282,46 @@ final class MobileAppModel { } } + /// Trades a password (or a stored refresh token) for a token pair. + /// + /// The password is used once and never stored: only the rotating refresh + /// token is persisted. + private func authenticate( + baseURL: URL, + host: String, + password: String, + refreshToken: String + ) async throws -> TabminalAuthTokens { + let anonymous = TabminalServerEndpoint( + id: "main", + baseURL: baseURL, + host: host, + token: "", + isPrimary: true + ) + + if !password.isEmpty { + return try await apiClient.login( + server: anonymous, + password: password + ) + } + + return try await apiClient.refreshTokens( + server: anonymous, + refreshToken: refreshToken + ) + } + + /// The server rotates the refresh token on every issue, so the new one must + /// replace the stored copy or the next launch will present a dead token. + private func applyMainTokens(_ tokens: TabminalAuthTokens) { + mainAccessToken = tokens.accessToken + mainAccessTokenExpiresAt = tokens.accessTokenExpiresAt + mainRefreshToken = tokens.refreshToken + credentialStore.saveToken(tokens.refreshToken) + } + func logout(clearCredentials: Bool = true) { stopHeartbeat() if clearCredentials { @@ -288,7 +337,7 @@ final class MobileAppModel { isSubmittingHostDraft = false phase = .login mainPassword = "" - mainToken = "" + clearMainTokens() for workspace in workspaces.values { workspace.setPresented(false) @@ -296,6 +345,12 @@ final class MobileAppModel { workspaces.removeAll() } + private func clearMainTokens() { + mainAccessToken = "" + mainRefreshToken = "" + mainAccessTokenExpiresAt = nil + } + func restoreMainHostSessionIfNeeded() { guard !didAttemptRestore else { return @@ -303,20 +358,12 @@ final class MobileAppModel { didAttemptRestore = true guard let parsedURL = URL(string: mainServerURL), - let token = credentialStore.loadToken(), - !token.isEmpty + let refreshToken = credentialStore.loadToken(), + !refreshToken.isEmpty else { return } - let mainEndpoint = TabminalServerEndpoint( - id: "main", - baseURL: parsedURL, - host: mainHostName, - token: token, - isPrimary: true - ) - phase = .loading loginErrorMessage = "" isAuthenticating = true @@ -327,17 +374,31 @@ final class MobileAppModel { } do { - mainToken = token - try await bootstrap(mainEndpoint: mainEndpoint) + let tokens = try await authenticate( + baseURL: parsedURL, + host: mainHostName, + password: "", + refreshToken: refreshToken + ) + applyMainTokens(tokens) + + try await bootstrap( + mainEndpoint: TabminalServerEndpoint( + id: "main", + baseURL: parsedURL, + host: mainHostName, + token: tokens.accessToken, + isPrimary: true + ) + ) phase = .ready } catch let TabminalClientError.invalidStatus(code, _) where code == 401 || code == 403 { credentialStore.clearToken() - mainToken = "" + clearMainTokens() phase = .login loginErrorMessage = "Saved login expired." } catch { - mainToken = token phase = .login loginErrorMessage = Self.displayMessage(for: error) } @@ -542,21 +603,19 @@ final class MobileAppModel { return } - let inheritedToken = mainToken - let tokenToUse: String - if !hostDraft.password.isEmpty { - tokenToUse = TabminalPasswordHasher.sha256Hex(hostDraft.password) - } else { - switch hostEditorMode { - case .add: - tokenToUse = inheritedToken - case .edit(let hostID), .reconnect(let hostID): - tokenToUse = hostRecord(for: hostID)?.endpoint.token - ?? inheritedToken - } + // Every host issues its own access token, so a password typed here is + // exchanged against that host. Reusing the main host's token would + // always be rejected. + let password = hostDraft.password + let existingToken: String + switch hostEditorMode { + case .add: + existingToken = "" + case .edit(let hostID), .reconnect(let hostID): + existingToken = hostRecord(for: hostID)?.endpoint.token ?? "" } - guard !tokenToUse.isEmpty else { + guard !password.isEmpty || !existingToken.isEmpty else { hostDraftErrorMessage = "Password is required for this host." return } @@ -570,6 +629,13 @@ final class MobileAppModel { } do { + let tokenToUse = try await subHostToken( + baseURL: parsedURL, + host: hostDraft.host, + password: password, + existingToken: existingToken + ) + switch hostEditorMode { case .add: try await addHost( @@ -593,6 +659,32 @@ final class MobileAppModel { } } + /// Only the main host's refresh token is persisted, so a sub-host access + /// token is not renewed in the background: once it lapses the host drops to + /// `needsAuth` and the reconnect sheet asks for the password again. + private func subHostToken( + baseURL: URL, + host: String, + password: String, + existingToken: String + ) async throws -> String { + guard !password.isEmpty else { + return existingToken + } + + let candidate = TabminalServerEndpoint( + id: "candidate", + baseURL: baseURL, + host: host, + token: "", + isPrimary: false + ) + return try await apiClient.login( + server: candidate, + password: password + ).accessToken + } + func removeHost(_ hostID: String) { guard let host = hostRecord(for: hostID), !host.isPrimary else { return @@ -1026,6 +1118,7 @@ final class MobileAppModel { if Task.isCancelled { return } + await self.refreshMainTokensIfNeeded() await self.syncAllHosts(ensurePrimarySession: false) } } @@ -1036,6 +1129,57 @@ final class MobileAppModel { heartbeatTask = nil } + /// Access tokens live 15 minutes, so they are rotated shortly before expiry + /// rather than after a request has already failed. + /// + /// A failure here is left to the heartbeat's own `401` handling: it either + /// recovers on the next tick or ends in a clean logout. + private func refreshMainTokensIfNeeded() async { + guard !mainRefreshToken.isEmpty, + let expiry = mainAccessTokenExpiresAt, + expiry.timeIntervalSinceNow < Self.tokenRefreshLeadTime, + let record = hostRecord(for: "main") + else { + return + } + + do { + let tokens = try await apiClient.refreshTokens( + server: record.endpoint, + refreshToken: mainRefreshToken + ) + applyMainTokens(tokens) + propagateMainToken(tokens.accessToken) + } catch { + return + } + } + + /// The endpoint is a value type held by the host record, every workspace, + /// and each websocket feed, so a rotated token has to be pushed to all of + /// them or the next reconnect would present the old one. + private func propagateMainToken(_ token: String) { + guard let record = hostRecord(for: "main") else { + return + } + + let endpoint = TabminalServerEndpoint( + id: "main", + baseURL: record.endpoint.baseURL, + host: record.endpoint.host, + token: token, + isPrimary: true + ) + + updateHost("main") { current in + current.endpoint = endpoint + } + + for workspace in workspaces.values where workspace.hostID == "main" { + workspace.updateEndpoint(endpoint) + } + } + private func hostRecord(for hostID: String) -> HostRecord? { hosts.first { $0.id == hostID } } @@ -1121,6 +1265,7 @@ final class MobileAppModel { private static let defaultsMainURLKey = "tabminal.mobile.mainURL" private static let defaultsMainHostKey = "tabminal.mobile.mainHost" + private static let tokenRefreshLeadTime: TimeInterval = 60 } enum ConnectionError: LocalizedError { diff --git a/apps/Apple/App/ServerConnectionView.swift b/apps/Apple/App/ServerConnectionView.swift index 7d68d09..dd3b84d 100644 --- a/apps/Apple/App/ServerConnectionView.swift +++ b/apps/Apple/App/ServerConnectionView.swift @@ -133,7 +133,7 @@ struct ServerConnectionView: View { label: "Password", hint: model.hasStoredMainLogin ? "Optional. Leave empty to reuse the saved main-host login." - : "The app sends the same SHA-256 hash used by the web client." + : "Exchanged for a login token. The password is never stored or sent." ) { SecureField( "Password (optional, use saved login)", diff --git a/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift b/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift index a103c17..1026770 100644 --- a/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift +++ b/apps/Apple/Sources/TabminalMobileCore/TabminalAPIClient.swift @@ -18,6 +18,69 @@ public actor TabminalAPIClient { self.encoder = TabminalJSONCoding.makeEncoder() } + /// Runs the full challenge/login handshake and returns freshly issued + /// tokens. The challenge is single-use and lives ~30s, so it is requested + /// and consumed inside this one call. + public func login( + server: TabminalServerEndpoint, + password: String + ) async throws -> TabminalAuthTokens { + let challenge = try await requestChallenge(server: server) + let response = TabminalLoginChallengeResponder.response( + passwordHash: TabminalPasswordHasher.sha256Hex(password), + challenge: challenge + ) + let request = try makeRequest( + server: server, + path: "/api/auth/login", + method: "POST", + body: TabminalLoginRequest( + challengeId: challenge.challengeId, + response: response + ) + ) + return try await send( + request, + server: server, + decodeAs: TabminalAuthTokens.self + ) + } + + /// Exchanges a refresh token for a new token pair. The server rotates the + /// refresh token on every success, so the caller must persist the result. + public func refreshTokens( + server: TabminalServerEndpoint, + refreshToken: String + ) async throws -> TabminalAuthTokens { + let request = try makeRequest( + server: server, + path: "/api/auth/refresh", + method: "POST", + body: TabminalRefreshRequest(refreshToken: refreshToken) + ) + return try await send( + request, + server: server, + decodeAs: TabminalAuthTokens.self + ) + } + + public func requestChallenge( + server: TabminalServerEndpoint + ) async throws -> TabminalAuthChallenge { + let request = try makeRequest( + server: server, + path: "/api/auth/challenge", + method: "POST", + body: [String: String]() + ) + return try await send( + request, + server: server, + decodeAs: TabminalAuthChallenge.self + ) + } + public func heartbeat( server: TabminalServerEndpoint, updates: [TabminalSessionUpdate] diff --git a/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift b/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift index c300dfb..49edb61 100644 --- a/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift +++ b/apps/Apple/Sources/TabminalMobileCore/TabminalProtocolModels.swift @@ -160,6 +160,98 @@ public enum TabminalPasswordHasher { } } +public struct TabminalAuthChallenge: Codable, Sendable { + public let challengeId: String + public let salt: String + /// Kept as the raw string the server sent. It is signed verbatim, so + /// round-tripping it through `Date` would change the bytes and the server + /// would reject the response. + public let expiresAt: String + public let algorithm: String + + public init( + challengeId: String, + salt: String, + expiresAt: String, + algorithm: String + ) { + self.challengeId = challengeId + self.salt = salt + self.expiresAt = expiresAt + self.algorithm = algorithm + } +} + +public struct TabminalAuthTokens: Codable, Sendable { + public let accessToken: String + public let accessTokenExpiresAt: Date + public let refreshToken: String + public let refreshTokenExpiresAt: Date +} + +public struct TabminalLoginRequest: Codable, Sendable { + public let challengeId: String + public let response: String +} + +public struct TabminalRefreshRequest: Codable, Sendable { + public let refreshToken: String +} + +/// Computes the one-time login response for `tabminal-hmac-sha256-login-v1`. +/// +/// The password hash itself is never sent: the server recomputes this HMAC from +/// its own configured hash and compares in constant time. +public enum TabminalLoginChallengeResponder { + public static let messagePrefix = "tabminal-login-v1" + + public static func response( + passwordHash: String, + challenge: TabminalAuthChallenge + ) -> String { + let message = [ + messagePrefix, + challenge.challengeId, + challenge.salt, + challenge.expiresAt + ].joined(separator: ":") + + // The server keys the HMAC with the digest's raw bytes + // (`Buffer.from(hash, 'hex')`), not with its hex text. + let key = SymmetricKey( + data: Data(hexEncoded: passwordHash.lowercased()) + ) + let code = HMAC.authenticationCode( + for: Data(message.utf8), + using: key + ) + return code.map { String(format: "%02x", $0) }.joined() + } +} + +extension Data { + init(hexEncoded string: String) { + var bytes = [UInt8]() + bytes.reserveCapacity(string.count / 2) + + var index = string.startIndex + while index < string.endIndex, + let next = string.index( + index, + offsetBy: 2, + limitedBy: string.endIndex + ) { + guard let byte = UInt8(string[index ..< next], radix: 16) else { + break + } + bytes.append(byte) + index = next + } + + self = Data(bytes) + } +} + public struct TabminalClusterPayload: Codable, Sendable { public let servers: [TabminalClusterServer] diff --git a/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift b/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift index e85fa33..9ff5c4f 100644 --- a/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift +++ b/apps/Apple/Tests/TabminalMobileCoreTests/TabminalMobileCoreTests.swift @@ -50,6 +50,57 @@ func passwordHasherMatchesServerSha256Format() { ) } +@Test +func loginChallengeResponseMatchesServerHmac() { + // Vector produced with the server's own construction: + // HMAC-SHA256(key = raw bytes of SHA-256(password), + // message = "tabminal-login-v1:::") + let challenge = TabminalAuthChallenge( + challengeId: "9f8d1c2b-0000-4a1b-8c3d-5e6f70819293", + salt: "Zm9vYmFyLXNhbHQtdmFsdWU", + expiresAt: "2026-04-10T15:00:30.000Z", + algorithm: "tabminal-hmac-sha256-login-v1" + ) + let passwordHash = TabminalPasswordHasher.sha256Hex( + "correct horse battery staple" + ) + + #expect( + passwordHash + == "c4bbcb1fbec99d65bf59d85c8cb62ee2db963f0fe106f483d9afa73bd4e39a8a" + ) + #expect( + TabminalLoginChallengeResponder.response( + passwordHash: passwordHash, + challenge: challenge + ) == "8ae9f238b25725db9274c181fca261d65c9af71790434190d77db54b03b62844" + ) +} + +@Test +func authTokensDecodeFractionalISODates() throws { + let json = """ + { + "accessToken": "ta_abc", + "accessTokenExpiresAt": "2026-04-10T15:00:00.000Z", + "refreshToken": "tr_def", + "refreshTokenExpiresAt": "2026-07-09T15:00:00.000Z" + } + """ + + let tokens = try TabminalJSONCoding.makeDecoder().decode( + TabminalAuthTokens.self, + from: Data(json.utf8) + ) + + #expect(tokens.accessToken == "ta_abc") + #expect(tokens.refreshToken == "tr_def") + #expect( + tokens.accessTokenExpiresAt + == Date(timeIntervalSince1970: 1_775_833_200) + ) +} + @Test func clusterPayloadDecodesBackendBaseUrlKey() throws { let json = """ diff --git a/apps/Apple/project.yml b/apps/Apple/project.yml index 965a36a..b5aa174 100644 --- a/apps/Apple/project.yml +++ b/apps/Apple/project.yml @@ -34,6 +34,9 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.leask.tabminal.mobile PRODUCT_NAME: Tabminal Mobile + IPHONEOS_DEPLOYMENT_TARGET: "18.0" + MACOSX_DEPLOYMENT_TARGET: "15.0" + XROS_DEPLOYMENT_TARGET: "2.0" GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDarkContent diff --git a/apps/Apple/run-device.sh b/apps/Apple/run-device.sh new file mode 100755 index 0000000..e86ce0a --- /dev/null +++ b/apps/Apple/run-device.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_PATH="${ROOT_DIR}/TabminalMobileApp.xcodeproj" +SCHEME="TabminalMobileApp" +APP_BUNDLE="${ROOT_DIR}/build/Build/Products/Debug-iphoneos/Tabminal Mobile.app" +DEVELOPMENT_TEAM="${TABMINAL_DEVELOPMENT_TEAM:-258X46W652}" +APP_ID="${TABMINAL_BUNDLE_ID:-com.miramiao.tabminal.mobile}" +source "${ROOT_DIR}/xcodebuild-lock.sh" + +cd "${ROOT_DIR}" + +tabminal_acquire_xcodebuild_lock + +xcodegen generate >/dev/null + +DEVICE_UDID="${1:-${TABMINAL_DEVICE_UDID:-}}" + +if [[ -z "${DEVICE_UDID}" ]]; then + DEVICE_JSON="$(mktemp -t tabminal-devices)" + trap 'rm -f "${DEVICE_JSON}"' EXIT + xcrun devicectl list devices --quiet --json-output "${DEVICE_JSON}" + DEVICE_UDID="$( + python3 -c ' +import json +import sys + +with open(sys.argv[1]) as handle: + payload = json.load(handle) + +for device in payload["result"]["devices"]: + if device["hardwareProperties"]["platform"] != "iOS": + continue + if device["connectionProperties"]["tunnelState"] == "unavailable": + continue + print(device["hardwareProperties"]["udid"]) + break +' "${DEVICE_JSON}" + )" +fi + +if [[ -z "${DEVICE_UDID}" ]]; then + echo "No connected iOS device found." >&2 + exit 1 +fi + +# ghostty-build-settings.sh needs bash 4 namerefs, so resolve the artifact here +# instead. Without a slice the app falls back to its text renderer. +GHOSTTY_XCFRAMEWORK="" +for candidate in \ + "${TABMINAL_GHOSTTY_XCFRAMEWORK_PATH:-}" \ + "${TABMINAL_GHOSTTY_REPO_PATH:+${TABMINAL_GHOSTTY_REPO_PATH}/macos/GhosttyKit.xcframework}" \ + "${ROOT_DIR}/Vendor/Ghostty/GhosttyKit.xcframework"; do + if [[ -n "${candidate}" && -d "${candidate}" ]]; then + GHOSTTY_XCFRAMEWORK="${candidate}" + break + fi +done + +XCODEBUILD_ARGS=() +if [[ -n "${GHOSTTY_XCFRAMEWORK}" ]]; then + for lib in \ + "${GHOSTTY_XCFRAMEWORK}/ios-arm64/libghostty-fat.a" \ + "${GHOSTTY_XCFRAMEWORK}/ios-arm64/libghostty.a"; do + if [[ -f "${lib}" ]]; then + echo "[ghostty] Linking ${lib}" >&2 + XCODEBUILD_ARGS+=( + "OTHER_LDFLAGS=\$(inherited) -force_load ${lib} -lc++" + ) + break + fi + done +fi + +if [[ ${#XCODEBUILD_ARGS[@]} -eq 0 ]]; then + echo "[ghostty] No iphoneos slice found; using text fallback." >&2 + XCODEBUILD_ARGS+=("GCC_PREPROCESSOR_DEFINITIONS=\$(inherited)") +fi + +xcodebuild \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" \ + -sdk iphoneos \ + -destination "id=${DEVICE_UDID}" \ + -derivedDataPath "${ROOT_DIR}/build" \ + -allowProvisioningUpdates \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + CODE_SIGN_STYLE=Automatic \ + CODE_SIGN_IDENTITY="Apple Development" \ + DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM}" \ + PRODUCT_BUNDLE_IDENTIFIER="${APP_ID}" \ + IPHONEOS_DEPLOYMENT_TARGET=18.0 \ + "${XCODEBUILD_ARGS[@]}" \ + build + +xcrun devicectl device install app --device "${DEVICE_UDID}" "${APP_BUNDLE}" +xcrun devicectl device process launch --device "${DEVICE_UDID}" "${APP_ID}" diff --git a/src/terminal-manager.mjs b/src/terminal-manager.mjs index b043916..3c07c95 100644 --- a/src/terminal-manager.mjs +++ b/src/terminal-manager.mjs @@ -8,18 +8,66 @@ import { TerminalSession } from './terminal-session.mjs'; import * as persistence from './persistence.mjs'; import { config } from './config.mjs'; +function isExecutable(filePath) { + if (!filePath) return false; + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveShellByName(name) { + if (!name) return name; + if (path.isAbsolute(name)) return name; + const candidates = process.platform === 'darwin' + ? ['/opt/homebrew/bin', '/usr/local/bin', '/bin', '/usr/bin'] + : ['/usr/local/bin', '/usr/bin', '/bin']; + for (const dir of candidates) { + const candidate = path.join(dir, name); + if (isExecutable(candidate)) return candidate; + } + return name; +} + +function pickFirstExecutable(candidates) { + for (const candidate of candidates) { + if (candidate && isExecutable(candidate)) return candidate; + } + return null; +} + function resolveShell() { if (config.shell) { - return config.shell; + const configured = resolveShellByName(config.shell); + if (path.isAbsolute(configured) && isExecutable(configured)) { + return configured; + } + return configured; } if (process.platform === 'win32') { return process.env.COMSPEC || 'cmd.exe'; } - // Try to use Homebrew installed bash if available (newer version) - if (fs.existsSync('/opt/homebrew/bin/bash')) { - return '/opt/homebrew/bin/bash'; - } - return '/bin/bash'; + const envShell = process.env.SHELL; + const platformCandidates = process.platform === 'darwin' + ? [ + envShell, + '/opt/homebrew/bin/zsh', + '/usr/local/bin/zsh', + '/bin/zsh', + '/opt/homebrew/bin/bash', + '/usr/local/bin/bash', + '/bin/bash', + '/bin/sh' + ] + : [ + envShell, + '/usr/bin/bash', + '/bin/bash', + '/bin/sh' + ]; + return pickFirstExecutable(platformCandidates) || '/bin/sh'; } const historyLimit = config.historyLimit; @@ -230,7 +278,10 @@ export class TerminalManager { _createPtySession(options = {}) { const id = options.id || crypto.randomUUID(); - const shell = options.shell || resolveShell(); + const requestedShell = options.shell || resolveShell(); + const shell = path.isAbsolute(requestedShell) + ? requestedShell + : resolveShellByName(requestedShell); const initialCwd = options.cwd || process.env.TABMINAL_CWD || os.homedir(); @@ -238,6 +289,12 @@ export class TerminalManager { ...process.env, ...(options.env || {}) }; + if (!options.directSpawn + && !options.spawnCommand + && path.isAbsolute(shell) + && env.SHELL !== shell) { + env.SHELL = shell; + } let spawnShell = options.spawnCommand || shell; let args = Array.isArray(options.spawnArgs) ? options.spawnArgs : []; let initDirPath = null; @@ -308,6 +365,24 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker) const cols = Number.isFinite(options.cols) ? options.cols : this.lastCols; const rows = Number.isFinite(options.rows) ? options.rows : this.lastRows; + if (path.isAbsolute(spawnShell) && !isExecutable(spawnShell)) { + throw new Error( + `Shell binary not found or not executable: ${spawnShell}` + + ` (requested via "${requestedShell}")` + ); + } + let cwdStat; + try { + cwdStat = fs.statSync(initialCwd); + } catch (cwdErr) { + throw new Error( + `cwd unreadable: ${initialCwd} - ${cwdErr.message}` + ); + } + if (!cwdStat.isDirectory()) { + throw new Error(`cwd is not a directory: ${initialCwd}`); + } + let ptyProcess; try { const ptyOptions = { @@ -322,24 +397,32 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker) } ptyProcess = pty.spawn(spawnShell, args, ptyOptions); } catch (err) { + const errProps = {}; + if (err) { + for (const key of Object.getOwnPropertyNames(err)) { + try { errProps[key] = err[key]; } catch {} + } + } const spawnInfo = { shell: spawnShell, - requestedShell: shell, + requestedShell, args, cwd: initialCwd, + cwdExists: fs.existsSync(initialCwd), + shellExecutable: isExecutable(spawnShell), cols, rows, + platform: process.platform, + arch: process.arch, + nodeVersion: process.version, env: { SHELL: env.SHELL, TERM: env.TERM, PATH: env.PATH, - HOME: env.HOME + HOME: env.HOME, + ZDOTDIR: env.ZDOTDIR }, - error: { - message: err?.message, - code: err?.code, - errno: err?.errno - } + error: errProps }; console.error('[Manager] Failed to spawn PTY', spawnInfo); throw err;