diff --git a/Makefile b/Makefile index 4ee146c1..8d5e6044 100644 --- a/Makefile +++ b/Makefile @@ -165,7 +165,7 @@ app: check-toolchain release-facts ## Build the release app bundle run-app: check-toolchain ## Build and open the local app bundle ./scripts/build-and-run-vifty.sh -install: check-toolchain ## Build and install to /Applications +install: check-toolchain app ## Build and install to /Applications CONFIGURATION="$(CONFIGURATION)" ./scripts/install-vifty.sh install-public-release: ## Verify and install the exact current published release archive @@ -271,7 +271,7 @@ test-full: check-toolchain ## Run the full XCTest suite, including slow evidence swift test $(SWIFT_BUILD_ARGS) $(SWIFT_TEST_WARNING_ARGS) verify: check-toolchain ## Run fast local trust gates without installing - /bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh + @for script in scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh; do /bin/bash -n "$$script" || exit $$?; done $(MAKE) release-facts scripts/check-community-standards.sh scripts/validate-release-metadata.sh --mode "$(RELEASE_METADATA_MODE)" diff --git a/Sources/Vifty/AgentWorkflowSupport.swift b/Sources/Vifty/AgentWorkflowSupport.swift index aab51594..ad2817ae 100644 --- a/Sources/Vifty/AgentWorkflowSupport.swift +++ b/Sources/Vifty/AgentWorkflowSupport.swift @@ -26,39 +26,18 @@ extension ViftyAgentRuleWorkloadCommandMode { extension AgentWorkflowSupport { static let safeWorkloadCommandTemplates: [WorkloadCommandTemplate] = ViftyCtlWorkloadTemplate.auditedTemplates - static func agentRule( - bundleURL: URL = Bundle.main.bundleURL, - fileManager: FileManager = .default - ) -> String { - ViftyAgentRule.rule(bundleURL: bundleURL, fileManager: fileManager) - } - @discardableResult @MainActor static func copyAgentRule( bundleURL: URL = Bundle.main.bundleURL, pasteboard: NSPasteboard = .general ) -> String { - let rule = agentRule(bundleURL: bundleURL) + let rule = ViftyAgentRule.rule(bundleURL: bundleURL) pasteboard.clearContents() pasteboard.setString(rule, forType: .string) return rule } - static func workloadCommand( - _ template: WorkloadCommandTemplate, - mode: WorkloadCommandMode, - bundleURL: URL = Bundle.main.bundleURL, - fileManager: FileManager = .default - ) -> String { - ViftyAgentRule.workloadCommand( - template, - mode: mode, - bundleURL: bundleURL, - fileManager: fileManager - ) - } - @discardableResult @MainActor static func copyWorkloadCommand( @@ -67,7 +46,7 @@ extension AgentWorkflowSupport { bundleURL: URL = Bundle.main.bundleURL, pasteboard: NSPasteboard = .general ) -> String { - let command = workloadCommand(template, mode: mode, bundleURL: bundleURL) + let command = ViftyAgentRule.workloadCommand(template, mode: mode, bundleURL: bundleURL) pasteboard.clearContents() pasteboard.setString(command, forType: .string) return command diff --git a/Sources/Vifty/AppModel+Control.swift b/Sources/Vifty/AppModel+Control.swift index 95f225fe..207d0dea 100644 --- a/Sources/Vifty/AppModel+Control.swift +++ b/Sources/Vifty/AppModel+Control.swift @@ -4,14 +4,6 @@ import ViftyCore @MainActor extension AppModel { - func applyModeSelection() { - if selectedMode == .auto { - restoreAuto() - } else { - markFanControlDraftPending() - } - } - func performModeSelectionAction() { Task { await performModeSelectionActionNow() } } @@ -47,10 +39,6 @@ extension AppModel { ) } - func applyPendingFanControl() { - Task { _ = await applyCurrentModeSelection() } - } - @discardableResult func applyCurrentModeSelection() async -> FanControlApplyResult { if selectedMode == .auto { @@ -350,10 +338,6 @@ extension AppModel { fanControlSessionController.fanMode(for: draft) } - func applyCurveOverrides() { - markFanControlDraftPending() - } - func restoreAutoIfManualSessionExpired() async -> FanControlSessionOperation? { guard fanControlSessionController.shouldRestoreExpiredManualSession( selectedMode: selectedMode, diff --git a/Sources/Vifty/CodexUsage.swift b/Sources/Vifty/CodexUsage.swift index 1717cd44..97c634df 100644 --- a/Sources/Vifty/CodexUsage.swift +++ b/Sources/Vifty/CodexUsage.swift @@ -198,14 +198,6 @@ struct CodexUsageReader { return latest?.snapshot } - private func latestUsageEvent(in url: URL) -> (timestamp: String, snapshot: CodexUsageSnapshot)? { - for line in candidateLines(fromTailOf: url) { - guard let event = parseEvent(line, sourceURL: url) else { continue } - return event - } - return nil - } - private func usageFiles() -> [URL] { let sessionsURL = codexHome.appendingPathComponent("sessions", isDirectory: true) guard let enumerator = fileManager.enumerator( @@ -231,8 +223,8 @@ struct CodexUsageReader { .map(\.url) } - private func candidateLines(fromTailOf url: URL) -> [String] { - guard let handle = try? FileHandle(forReadingFrom: url) else { return [] } + private func latestUsageEvent(in url: URL) -> (timestamp: String, snapshot: CodexUsageSnapshot)? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } defer { try? handle.close() } let size = (try? handle.seekToEnd()) ?? 0 @@ -240,22 +232,17 @@ struct CodexUsageReader { do { try handle.seek(toOffset: offset) } catch { - return [] + return nil } let data = (try? handle.readToEnd()) ?? Data() - return candidateLines(in: data) - } - - private func candidateLines(in data: Data) -> [String] { - var matches: [String] = [] for line in String(decoding: data, as: UTF8.self) .split(separator: "\n", omittingEmptySubsequences: true) .reversed() { guard line.contains("token_count"), line.contains("rate_limits") else { continue } - matches.append(String(line)) + if let event = parseEvent(String(line), sourceURL: url) { return event } } - return matches + return nil } private func parseEvent(_ line: String, sourceURL: URL) -> (timestamp: String, snapshot: CodexUsageSnapshot)? { diff --git a/Sources/Vifty/DaemonInstallService.swift b/Sources/Vifty/DaemonInstallService.swift index 3089062b..9f32e571 100644 --- a/Sources/Vifty/DaemonInstallService.swift +++ b/Sources/Vifty/DaemonInstallService.swift @@ -139,7 +139,7 @@ struct DaemonLifecycleScriptLoader: Sendable { // This digest is compiled into the signed app executable. The resource is // read once into an immutable Data snapshot and only that snapshot runs. // Update it intentionally whenever vifty-helper-lifecycle.sh changes. - let expectedSHA256 = "d0ba8e8ed28cd3b85d1df53db5defa30334ee933a00f1d76b09736b0c21a78bd" + let expectedSHA256 = "3777a71c14046f0ef9d27a575d604300683914bdb03e0a79e9fb0c6cf6f6e18f" let maximumSize = 256 * 1_024 let descriptor = Darwin.open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) guard descriptor >= 0 else { diff --git a/Sources/Vifty/DaemonInstaller.swift b/Sources/Vifty/DaemonInstaller.swift index 319aa550..b7a2cefb 100644 --- a/Sources/Vifty/DaemonInstaller.swift +++ b/Sources/Vifty/DaemonInstaller.swift @@ -286,6 +286,9 @@ final class DaemonInstaller: ObservableObject { return .failed } case .enabled: + // Keep SMAppService and launchd aligned when macOS reports a + // stale enabled record after a prior bootout or app replacement. + try? backend.register() return await runSafeLifecycle() case .unknown: statusText = "Helper registration state is unknown; restore Auto or reboot before repair" diff --git a/Sources/Vifty/FanControlSessionController.swift b/Sources/Vifty/FanControlSessionController.swift index 5ea51af2..22143f3f 100644 --- a/Sources/Vifty/FanControlSessionController.swift +++ b/Sources/Vifty/FanControlSessionController.swift @@ -185,14 +185,6 @@ struct FanControlSessionController { ) } - func previousSessionDeadline(for operation: FanControlSessionOperation) -> Date? { - guard let attempt = manualApplyAttempt, - attempt.operation == operation else { - return nil - } - return attempt.previousSessionExpiresAt - } - func fanMode(for draft: FanControlDraft) -> FanMode { switch draft.mode { case .auto: diff --git a/Sources/Vifty/HelperDiagnosticsSupport.swift b/Sources/Vifty/HelperDiagnosticsSupport.swift index 79c031b3..f29f8575 100644 --- a/Sources/Vifty/HelperDiagnosticsSupport.swift +++ b/Sources/Vifty/HelperDiagnosticsSupport.swift @@ -78,18 +78,6 @@ enum HelperDiagnosticsSupport { return command } - @discardableResult - @MainActor - static func copyDiagnoseCommand( - bundleURL: URL = Bundle.main.bundleURL, - pasteboard: NSPasteboard = .general - ) -> String { - let command = diagnoseCommand(bundleURL: bundleURL) - pasteboard.clearContents() - pasteboard.setString(command, forType: .string) - return command - } - private static func developmentToolURL( beside executableURL: URL?, fileManager: FileManager diff --git a/Sources/Vifty/HelperServiceManagementBridge.swift b/Sources/Vifty/HelperServiceManagementBridge.swift index c0c737df..0207a94c 100644 --- a/Sources/Vifty/HelperServiceManagementBridge.swift +++ b/Sources/Vifty/HelperServiceManagementBridge.swift @@ -280,7 +280,10 @@ enum HelperServiceManagementBridge { case .register: switch backend.state { case .enabled: - break + // macOS can retain the enabled SMAppService record after the + // launchd job was booted out. Re-submit the native service so + // the next XPC lookup can launch it again. + try backend.register() case .notRegistered: try backend.register() case .requiresApproval: diff --git a/Sources/Vifty/MenuBarTelemetryPrimeScheduler.swift b/Sources/Vifty/MenuBarTelemetryPrimeScheduler.swift index 1b2c3127..d0ead0f5 100644 --- a/Sources/Vifty/MenuBarTelemetryPrimeScheduler.swift +++ b/Sources/Vifty/MenuBarTelemetryPrimeScheduler.swift @@ -2,7 +2,6 @@ import Foundation @MainActor final class MenuBarTelemetryPrimeScheduler { - private var currentOperation: (@MainActor () async -> Void)? private var task: Task? var isPriming: Bool { @@ -12,15 +11,12 @@ final class MenuBarTelemetryPrimeScheduler { @discardableResult func schedule(_ operation: @escaping @MainActor () async -> Void) -> Bool { guard task == nil else { return false } - currentOperation = operation task = Task { @MainActor [weak self] in guard let self else { return } - let operation = self.currentOperation defer { - self.currentOperation = nil self.task = nil } - await operation?() + await operation() } return true } diff --git a/Sources/Vifty/SettingsGeneralView.swift b/Sources/Vifty/SettingsGeneralView.swift index f704f7c0..83f66160 100644 --- a/Sources/Vifty/SettingsGeneralView.swift +++ b/Sources/Vifty/SettingsGeneralView.swift @@ -56,7 +56,7 @@ struct SettingsGeneralView: View { Text("Temperature Curve").tag(ModeSelection.curve) } - Text(StartupModePresentation.resolve(model.startupMode).detail) + Text(StartupModePresentation.detail(for: model.startupMode)) .viftyFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/Sources/Vifty/StartupModePresentation.swift b/Sources/Vifty/StartupModePresentation.swift index 9ed74d23..6106a276 100644 --- a/Sources/Vifty/StartupModePresentation.swift +++ b/Sources/Vifty/StartupModePresentation.swift @@ -1,19 +1,10 @@ -struct StartupModePresentation: Equatable { - let detail: String - let requiresExplicitApply: Bool - - static func resolve(_ mode: ModeSelection) -> StartupModePresentation { +enum StartupModePresentation { + static func detail(for mode: ModeSelection) -> String { switch mode { case .auto: - return StartupModePresentation( - detail: "Starts in macOS Auto control.", - requiresExplicitApply: false - ) + "Starts in macOS Auto control." case .fixed, .curve: - return StartupModePresentation( - detail: "Preselects this mode as a draft; it does not change fan control at launch. Review the targets and choose Apply.", - requiresExplicitApply: true - ) + "Preselects this mode as a draft; it does not change fan control at launch. Review the targets and choose Apply." } } } diff --git a/Sources/ViftyCore/SMCClient.swift b/Sources/ViftyCore/SMCClient.swift index e96f2e9c..305e6a97 100644 --- a/Sources/ViftyCore/SMCClient.swift +++ b/Sources/ViftyCore/SMCClient.swift @@ -163,6 +163,10 @@ public final class SMCClient: @unchecked Sendable { var input = infoInput input.keyInfo = infoOutput.keyInfo + // AppleSMC expects the write envelope to declare the exact payload + // size. Leaving the discovered size implicit can accept F0Md while + // silently ignoring the following F{n}Tg write on protected Macs. + input.keyInfo.dataSize = UInt32(bytes.count) input.data8 = 6 for (index, byte) in bytes.enumerated() { input.bytes[index] = byte diff --git a/Sources/ViftyCore/SecureStorageDirectory.swift b/Sources/ViftyCore/SecureStorageDirectory.swift index 085b1861..a00ce75d 100644 --- a/Sources/ViftyCore/SecureStorageDirectory.swift +++ b/Sources/ViftyCore/SecureStorageDirectory.swift @@ -965,14 +965,6 @@ public final class SecureStorageDirectory: @unchecked Sendable { lhs.st_dev == rhs.st_dev && lhs.st_ino == rhs.st_ino } - private static func sameOptionalIdentity(_ lhs: stat?, _ rhs: stat?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): true - case (.some(let lhs), .some(let rhs)): sameIdentity(lhs, rhs) - default: false - } - } - private static func sameOptionalVersion(_ lhs: stat?, _ rhs: stat?) -> Bool { switch (lhs, rhs) { case (nil, nil): true diff --git a/Sources/ViftyCore/ViftyCtlRunner.swift b/Sources/ViftyCore/ViftyCtlRunner.swift index 888a66e8..a17eab85 100644 --- a/Sources/ViftyCore/ViftyCtlRunner.swift +++ b/Sources/ViftyCore/ViftyCtlRunner.swift @@ -25,10 +25,25 @@ public extension ViftyCtlDaemonRuntimeDiagnostic { installedDaemonPath: String = ViftyCtlDaemonRuntimeDiagnostic.standardInstalledDaemonPath ) -> ViftyCtlDaemonRuntimeDiagnostic { let fileManager = FileManager.default - let installedURL = URL(fileURLWithPath: installedDaemonPath, isDirectory: false) + let expectedURL = expectedDaemonURL(forExecutablePath: executablePath) + let modernBundleProgramPath: String? + if installedDaemonPath == ViftyCtlDaemonRuntimeDiagnostic.standardInstalledDaemonPath, + let expectedURL, + let description = launchdDescription(), + let pid = launchdPID(from: description), + let runningProcessPath = runningProcessPath(for: pid) { + modernBundleProgramPath = modernBundleProgramDaemonPath( + launchdDescription: description, + expectedDaemonPath: expectedURL.path, + runningProcessPath: runningProcessPath + ) + } else { + modernBundleProgramPath = nil + } + let effectiveInstalledDaemonPath = modernBundleProgramPath ?? installedDaemonPath + let installedURL = URL(fileURLWithPath: effectiveInstalledDaemonPath, isDirectory: false) let installedPresent = fileExists(installedURL, fileManager: fileManager) let installedSHA256 = installedPresent ? sha256Hex(of: installedURL) : nil - let expectedURL = expectedDaemonURL(forExecutablePath: executablePath) let expectedPresent = expectedURL.map { fileExists($0, fileManager: fileManager) } ?? false let expectedSHA256 = expectedPresent ? expectedURL.flatMap(sha256Hex(of:)) : nil let matchesExpectedDaemon: Bool? @@ -39,7 +54,7 @@ public extension ViftyCtlDaemonRuntimeDiagnostic { } return ViftyCtlDaemonRuntimeDiagnostic( - installedDaemonPath: installedDaemonPath, + installedDaemonPath: effectiveInstalledDaemonPath, installedDaemonPresent: installedPresent, installedDaemonSHA256: installedSHA256, expectedDaemonPath: expectedURL?.path, @@ -50,6 +65,27 @@ public extension ViftyCtlDaemonRuntimeDiagnostic { ) } + internal static func modernBundleProgramDaemonPath( + launchdDescription: String, + expectedDaemonPath: String, + runningProcessPath: String + ) -> String? { + guard launchdValue("managed_by", in: launchdDescription) == "com.apple.xpc.ServiceManagement", + launchdValue("state", in: launchdDescription) == "running", + launchdValue("program identifier", in: launchdDescription) == "Contents/MacOS/ViftyDaemon (mode: 2)", + launchdValue("parent bundle identifier", in: launchdDescription) == "tech.reidar.vifty", + launchdValue("job state", in: launchdDescription) == "running" else { + return nil + } + + let expectedPath = URL(fileURLWithPath: expectedDaemonPath, isDirectory: false) + .standardizedFileURL.path + let runningPath = URL(fileURLWithPath: runningProcessPath, isDirectory: false) + .standardizedFileURL.path + guard runningPath == expectedPath else { return nil } + return runningPath + } + private static func expectedDaemonURL(forExecutablePath executablePath: String?) -> URL? { guard let executablePath, !executablePath.isEmpty else { return nil @@ -64,6 +100,55 @@ public extension ViftyCtlDaemonRuntimeDiagnostic { .appendingPathComponent("ViftyDaemon", isDirectory: false) } + private static func launchdDescription() -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl", isDirectory: false) + process.arguments = ["print", "system/\(ViftyDaemonConstants.machServiceName)"] + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + do { + try process.run() + process.waitUntilExit() + } catch { + return nil + } + _ = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { return nil } + return String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + } + + private static func launchdPID(from description: String) -> pid_t? { + guard let value = launchdValue("pid", in: description), + let pid = Int32(value), + pid > 0 else { + return nil + } + return pid + } + + private static func launchdValue(_ key: String, in description: String) -> String? { + let prefix = "\(key) = " + for line in description.split(whereSeparator: \.isNewline) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix(prefix) { + return String(trimmed.dropFirst(prefix.count)) + } + } + return nil + } + + private static func runningProcessPath(for pid: pid_t) -> String? { + var buffer = [CChar](repeating: 0, count: 4_096) + let length = proc_pidpath(pid, &buffer, UInt32(buffer.count)) + guard length > 0 else { return nil } + return String( + decoding: buffer.prefix(Int(length)).map { UInt8(bitPattern: $0) }, + as: UTF8.self + ) + } + private static func fileExists(_ url: URL, fileManager: FileManager) -> Bool { var isDirectory: ObjCBool = false return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && !isDirectory.boolValue @@ -1558,7 +1643,7 @@ public struct ViftyCtlRunner: Sendable { && report.blockers.isEmpty && report.token?.operation == operation return ViftyCtlResult( - stdout: try encodeJSON(report) + "\n", + stdout: try encodeJSON(report, dateEncodingStrategy: .secondsSince1970) + "\n", exitCode: authorizedShape ? 0 : 75 ) case .helperMaintenanceConsume(let operation, let reportPath): @@ -1910,9 +1995,13 @@ public struct ViftyCtlRunner: Sendable { return try format(status, json: false) } - private func encodeJSON(_ value: T) throws -> String { + private func encodeJSON( + _ value: T, + dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate + ) throws -> String { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = dateEncodingStrategy let data = try encoder.encode(value) return String(decoding: data, as: UTF8.self) } diff --git a/Sources/ViftyDaemon/main.swift b/Sources/ViftyDaemon/main.swift index ba0ec8b1..a09287eb 100644 --- a/Sources/ViftyDaemon/main.swift +++ b/Sources/ViftyDaemon/main.swift @@ -28,7 +28,7 @@ private final class ListenerDelegate: NSObject, NSXPCListenerDelegate { signal(SIGTERM, SIG_IGN) let terminationGate = DaemonTerminationSignalGate() -let terminationSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global()) +let terminationSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) terminationSource.setEventHandler { terminationGate.requestTermination() } @@ -37,7 +37,7 @@ terminationSource.resume() do { let service = try await DaemonService.bootstrap() terminationGate.installHandler { - Task { + Task.detached { do { let report = try await service.prepareVoluntaryTermination() guard report.safeToStop, @@ -61,8 +61,9 @@ do { let listener = NSXPCListener(machServiceName: ViftyDaemonConstants.machServiceName) listener.delegate = delegate listener.resume() - withExtendedLifetime((delegate, terminationSource, terminationGate)) { - RunLoop.main.run() + while !Task.isCancelled { + _ = (delegate, listener, terminationSource, terminationGate) + try? await Task.sleep(for: .seconds(86_400)) } } catch { FileHandle.standardError.write(Data("ViftyDaemon startup failed: \(error.localizedDescription)\n".utf8)) diff --git a/Sources/ViftyDaemonSupport/DaemonLifecycleCoordinator.swift b/Sources/ViftyDaemonSupport/DaemonLifecycleCoordinator.swift index 468ceb11..2dad87c5 100644 --- a/Sources/ViftyDaemonSupport/DaemonLifecycleCoordinator.swift +++ b/Sources/ViftyDaemonSupport/DaemonLifecycleCoordinator.swift @@ -416,12 +416,12 @@ public actor DaemonLifecycleCoordinator { "The exact bundled ViftyHelper digest is unavailable." ) } - let issuedAt = now() + let issuedAt = Self.wireStableDate(now()) token = HelperMaintenanceToken( tokenID: UUID().uuidString, operation: operation, issuedAt: issuedAt, - expiresAt: issuedAt.addingTimeInterval(tokenTTL), + expiresAt: Self.wireStableDate(issuedAt.addingTimeInterval(tokenTTL)), bootSessionID: bootSessionID(), daemonSessionID: daemonSessionID, journalGeneration: await journalGeneration(), @@ -666,6 +666,13 @@ public actor DaemonLifecycleCoordinator { } } + // Maintenance tokens cross JSON/XPC boundaries whose date encoding is + // microsecond precision. Canonicalize at issuance so strict token binding + // survives that round trip without accepting a caller-adjusted timestamp. + private static func wireStableDate(_ date: Date) -> Date { + Date(timeIntervalSince1970: (date.timeIntervalSince1970 * 1_000_000).rounded() / 1_000_000) + } + private static func isCleanOSOwnership(_ status: FanControlOwnershipStatus) -> Bool { status.protocolVersion >= FanControlProtocolVersion.current && status.owner == nil diff --git a/Sources/ViftyFanControlSafety/LocalFanHelperClient.swift b/Sources/ViftyFanControlSafety/LocalFanHelperClient.swift index 11fd7602..e937b499 100644 --- a/Sources/ViftyFanControlSafety/LocalFanHelperClient.swift +++ b/Sources/ViftyFanControlSafety/LocalFanHelperClient.swift @@ -44,6 +44,8 @@ public struct LocalFanHelperClient: Sendable { private let smcFactory: @Sendable () throws -> any SMCConnection private let unlockTimeoutSeconds: TimeInterval private let unlockRetryIntervalSeconds: TimeInterval + private let forceTestSettleSeconds: TimeInterval + private let targetReadbackTimeoutSeconds: TimeInterval private let monotonicNow: @Sendable () -> TimeInterval private let sleep: @Sendable (TimeInterval) -> Void @@ -55,6 +57,8 @@ public struct LocalFanHelperClient: Sendable { smcFactory: @escaping @Sendable () throws -> any SMCConnection, unlockTimeoutSeconds: TimeInterval = 10, unlockRetryIntervalSeconds: TimeInterval = 0.1, + forceTestSettleSeconds: TimeInterval = 3, + targetReadbackTimeoutSeconds: TimeInterval = 2, monotonicNow: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, @@ -65,6 +69,8 @@ public struct LocalFanHelperClient: Sendable { self.smcFactory = smcFactory self.unlockTimeoutSeconds = max(0, unlockTimeoutSeconds) self.unlockRetryIntervalSeconds = max(0, unlockRetryIntervalSeconds) + self.forceTestSettleSeconds = max(0, forceTestSettleSeconds) + self.targetReadbackTimeoutSeconds = max(0, targetReadbackTimeoutSeconds) self.monotonicNow = monotonicNow self.sleep = sleep } @@ -108,7 +114,7 @@ public struct LocalFanHelperClient: Sendable { var warnings = plan.warnings do { - try perform(plan.automaticMode, smc: smc) + try writeAndConfirmAutomaticModeWithRetry(plan: plan, smc: smc) if let hygieneTarget = plan.hygieneTarget { do { @@ -162,25 +168,32 @@ public struct LocalFanHelperClient: Sendable { let plan = try preflight(fan: fan, requestedRPM: rpm, smc: smc) do { - let usedForceTest = try enterManualMode(plan: plan, smc: smc) + var usedForceTest = try enterManualMode(plan: plan, smc: smc) guard let requestedTarget = plan.requestedTarget else { throw ViftyError.helperRejected("Fixed-RPM preflight produced no target write.") } - try perform(requestedTarget, smc: smc) - - if (usedForceTest || !plan.forceTestInitiallyDisabled), - let forceTestDisable = plan.forceTestDisable { - try perform(forceTestDisable, smc: smc) - } + usedForceTest = try writeTargetWithFallback( + requestedTarget, + expectedRPM: rpm, + plan: plan, + smc: smc, + usedForceTest: usedForceTest + ) let observation = observe(plan: plan, smc: smc) + let forceTestMustRemainEnabled = !plan.forceTestInitiallyDisabled || usedForceTest + let forceTestStateConfirmed = forceTestMustRemainEnabled + ? !observation.forceTestDisabled + : observation.forceTestDisabled guard observation.errors.isEmpty, observation.mode == .forced, observation.targetRPM == rpm, - observation.forceTestDisabled else { + forceTestStateConfirmed else { throw ReadbackMismatch( message: readbackMessage( - expected: "Forced at \(rpm) RPM with Ftst disabled", + expected: forceTestMustRemainEnabled + ? "Forced at \(rpm) RPM with Ftst enabled" + : "Forced at \(rpm) RPM with Ftst disabled", observation: observation ) ) @@ -204,6 +217,123 @@ public struct LocalFanHelperClient: Sendable { } } + private func writeTargetWithFallback( + _ target: PreparedWrite, + expectedRPM: Int, + plan: MutationPlan, + smc: any SMCConnection, + usedForceTest: Bool + ) throws -> Bool { + do { + try writeAndConfirmTargetWithRetry( + target, + expectedRPM: expectedRPM, + smc: smc, + timeoutSeconds: targetReadbackTimeoutSeconds + ) + return usedForceTest + } catch let mismatch as ReadbackMismatch { + guard let forceTestEnable = plan.forceTestEnable else { + throw mismatch + } + if !usedForceTest { + try resetToOSManagedModeBeforeUnlock(plan: plan, smc: smc) + try perform(forceTestEnable, smc: smc) + } + _ = try retryManualMode( + plan: plan, + smc: smc, + initialError: mismatch, + usedForceTest: true + ) + try writeAndConfirmTargetWithRetry( + target, + expectedRPM: expectedRPM, + smc: smc, + timeoutSeconds: unlockTimeoutSeconds + ) + return true + } + } + + private func resetToOSManagedModeBeforeUnlock( + plan: MutationPlan, + smc: any SMCConnection + ) throws { + try writeAndConfirmAutomaticModeWithRetry(plan: plan, smc: smc) + } + + private func writeAndConfirmTargetWithRetry( + _ target: PreparedWrite, + expectedRPM: Int, + smc: any SMCConnection, + timeoutSeconds: TimeInterval + ) throws { + try retryReadback(timeoutSeconds: timeoutSeconds) { + try writeAndConfirmTarget(target, expectedRPM: expectedRPM, smc: smc) + } + } + + private func writeAndConfirmAutomaticModeWithRetry( + plan: MutationPlan, + smc: any SMCConnection + ) throws { + try retryReadback(timeoutSeconds: targetReadbackTimeoutSeconds) { + try perform(plan.automaticMode, smc: smc) + let value = try smc.read(plan.modeKey) + guard let rawMode = SMCDecoding.decodeFanControlByte(value), + let mode = FanHardwareMode(rawValue: Int(rawMode)), + isOSManaged(mode) else { + throw ReadbackMismatch( + message: "Auto mode write was not confirmed for \(plan.modeKey)." + ) + } + } + } + + private func retryReadback( + timeoutSeconds: TimeInterval, + operation: () throws -> Void + ) throws { + var lastError: Error? + let deadline = monotonicNow() + timeoutSeconds + while true { + do { + try operation() + return + } catch { + lastError = error + } + + guard monotonicNow() < deadline, + unlockRetryIntervalSeconds > 0 else { + break + } + sleep(unlockRetryIntervalSeconds) + } + + throw lastError ?? ViftyError.helperRejected( + "Fan target write could not be confirmed." + ) + } + + private func writeAndConfirmTarget( + _ target: PreparedWrite, + expectedRPM: Int, + smc: any SMCConnection + ) throws { + try perform(target, smc: smc) + let value = try smc.read(target.key) + guard let actualRPM = SMCDecoding.decodeFanTargetRPM(value), + actualRPM == expectedRPM else { + let observedRPM = SMCDecoding.decodeFanTargetRPM(value) + .map(String.init) ?? "missing" + throw ReadbackMismatch( + message: "Fan target write was not confirmed for \(target.key): expected \(expectedRPM) RPM, observed \(observedRPM)" + ) + } + } + private func preflight( fan: Fan, requestedRPM: Int?, @@ -356,43 +486,76 @@ public struct LocalFanHelperClient: Sendable { smc: any SMCConnection ) throws -> Bool { do { - try perform(plan.manualMode, smc: smc) + try writeAndConfirmManualMode(plan: plan, smc: smc) return false } catch { let directError = error - guard let forceTestEnable = plan.forceTestEnable else { - throw directError + if let forceTestEnable = plan.forceTestEnable { + do { + try perform(forceTestEnable, smc: smc) + } catch { + throw ViftyError.helperRejected( + "Manual mode write failed (\(describe(directError))); Ftst unlock failed (\(describe(error)))." + ) + } + return try retryManualMode( + plan: plan, + smc: smc, + initialError: directError, + usedForceTest: true + ) } + return try retryManualMode( + plan: plan, + smc: smc, + initialError: directError, + usedForceTest: false + ) + } + } + + private func retryManualMode( + plan: MutationPlan, + smc: any SMCConnection, + initialError: Error, + usedForceTest: Bool + ) throws -> Bool { + let deadline = monotonicNow() + unlockTimeoutSeconds + let settleDeadline = monotonicNow() + + (usedForceTest && unlockRetryIntervalSeconds > 0 ? forceTestSettleSeconds : 0) + var lastError = initialError + while true { do { - try perform(forceTestEnable, smc: smc) + try writeAndConfirmManualMode(plan: plan, smc: smc) + if monotonicNow() >= settleDeadline { + return usedForceTest + } } catch { - throw ViftyError.helperRejected( - "Manual mode write failed (\(describe(directError))); Ftst unlock failed (\(describe(error)))." - ) + lastError = error } - let deadline = monotonicNow() + unlockTimeoutSeconds - var lastError = directError - while true { - do { - try perform(plan.manualMode, smc: smc) - return true - } catch { - lastError = error - } - - guard monotonicNow() < deadline, - unlockRetryIntervalSeconds > 0 else { - break - } - sleep(unlockRetryIntervalSeconds) + guard monotonicNow() < deadline, + unlockRetryIntervalSeconds > 0 else { + break } + sleep(unlockRetryIntervalSeconds) + } + if usedForceTest { throw ViftyError.helperRejected( "Fan control remained protected after Ftst unlock attempt: \(describe(lastError))" ) } + throw lastError + } + + private func writeAndConfirmManualMode(plan: MutationPlan, smc: any SMCConnection) throws { + try perform(plan.manualMode, smc: smc) + let value = try smc.read(plan.modeKey) + guard SMCDecoding.decodeFanControlByte(value) == 1 else { + throw ReadbackMismatch(message: "Manual mode write was not confirmed for \(plan.modeKey).") + } } private func failAfterMutation( diff --git a/Tests/Ruby/InstallerLifecycleTrustContractTests.rb b/Tests/Ruby/InstallerLifecycleTrustContractTests.rb index bd7d982a..a358efa0 100644 --- a/Tests/Ruby/InstallerLifecycleTrustContractTests.rb +++ b/Tests/Ruby/InstallerLifecycleTrustContractTests.rb @@ -3,6 +3,7 @@ require "json" require "minitest/autorun" require "open3" +require "tmpdir" class InstallerLifecycleTrustContractTests < Minitest::Test ROOT = File.expand_path("../..", __dir__) @@ -62,6 +63,30 @@ def run_installer(*arguments, environment: {}) ) end + def test_private_executable_copy_does_not_inherit_immutable_flags + Dir.mktmpdir("vifty-copy-", File.join(ROOT, ".build")) do |dir| + source = File.join(dir, "source") + destination = File.join(dir, "copy") + File.write(source, "#!/bin/sh\nexit 0\n") + File.chmod(0o500, source) + assert system("/usr/bin/chflags", "uchg", source) + begin + script = installer_function("system_tool_environment") + "\n" + + installer_function("sha256_file") + "\n" + + installer_function("copy_stable_executable_to_run_dir") + + "\n" + 'copy_stable_executable_to_run_dir "$1" "$2"' + output, error, status = Open3.capture3({"RUN_DIR" => dir}, "/bin/bash", "-c", script, "copy-test", source, destination) + assert status.success?, output + error + assert_equal File.binread(source), File.binread(destination) + assert_equal 0o500, File.stat(destination).mode & 0o777 + File.unlink(destination) + ensure + system("/usr/bin/chflags", "nouchg", source) + system("/usr/bin/chflags", "nouchg", destination) if File.exist?(destination) + end + end + end + def test_replacement_state_has_a_dedicated_durable_ledger assert_includes lifecycle, 'ROOT_REPLACEMENT_RECORD="${EXECUTION_DIR}/replacement-state-v1.json"' assert_includes lifecycle, "snapshot_prior_replacement_record" @@ -69,6 +94,19 @@ def test_replacement_state_has_a_dedicated_durable_ledger assert_match(/snapshot_prior_replacement_record[\s\S]+ROOT_REPLACEMENT_RECORD/, lifecycle) end + def test_control_app_is_a_narrow_uninstall_source_and_target_stays_ledger_bound + assert_includes lifecycle, "--control-app" + assert_includes lifecycle, 'CONTROL_APP_EXPLICIT=0' + assert_includes lifecycle, 'CONTROL_APP_PATH="${APP_PATH}"' + assert_includes lifecycle, '--control-app is only valid for uninstall or repair replacement prepare.' + assert_includes lifecycle, 'payload[:controlApp] = control_app unless control_app == app' + assert_match(/VIFTY_CTL="\$\{CONTROL_APP_PATH\}\/Contents\/MacOS\/viftyctl"/, lifecycle) + assert_match(/release_prior_replacement_lock_after_quiesce[\s\S]+capture_bundle_binding "\$\{APP_PATH\}"/, lifecycle) + assert_includes lifecycle, 'PUBLIC_RECOVERY_HELPER_SHA256="4c467d99f7e59c2727f0e1a9b13de81772741d269b560ce6ca9fb605782f0d0f"' + assert_includes lifecycle, 'identity["kind"] == "developer-id"' + assert_includes lifecycle, 'identity.dig("componentSHA256", "ViftyHelper") == expected_helper_sha' + end + def test_flag_changes_are_journaled_and_reconciled_from_real_flags assert_includes lifecycle, "persist_replacement_flag_transition" assert_includes lifecycle, "replacement_tree_flag_state" diff --git a/Tests/ViftyCoreTests/AdHocXPCConfigurationScriptTests.swift b/Tests/ViftyCoreTests/AdHocXPCConfigurationScriptTests.swift index 5e5ad476..ae97c8f9 100644 --- a/Tests/ViftyCoreTests/AdHocXPCConfigurationScriptTests.swift +++ b/Tests/ViftyCoreTests/AdHocXPCConfigurationScriptTests.swift @@ -105,7 +105,8 @@ final class AdHocXPCConfigurationScriptTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) _ = try executableScript(root: buildApp, name: "Contents/MacOS/ViftyDaemon", body: "exit 0") _ = try executableScript(root: buildApp, name: "Contents/MacOS/ViftyHelper", body: "exit 0") - _ = try executableScript(root: destination, name: "Contents/MacOS/Vifty", body: "exit 0") + let registrationReport = "printf '%s\\n' '{\"action\":\"register\",\"state\":\"enabled\",\"complete\":true,\"operatorActionRequired\":false,\"maintenanceAuthorized\":false,\"tokenID\":null}'" + _ = try executableScript(root: destination, name: "Contents/MacOS/Vifty", body: registrationReport) _ = try executableScript(root: destination, name: "Contents/MacOS/ViftyDaemon", body: "exit 0") _ = try executableScript(root: destination, name: "Contents/MacOS/ViftyHelper", body: "exit 0") try FileManager.default.createDirectory( diff --git a/Tests/ViftyCoreTests/AgentRunSmokeReadinessScriptTests.swift b/Tests/ViftyCoreTests/AgentRunSmokeReadinessScriptTests.swift index 98f532e1..30e90443 100644 --- a/Tests/ViftyCoreTests/AgentRunSmokeReadinessScriptTests.swift +++ b/Tests/ViftyCoreTests/AgentRunSmokeReadinessScriptTests.swift @@ -149,6 +149,29 @@ final class AgentRunSmokeReadinessScriptTests: XCTestCase { XCTAssertEqual(try harness.loggedArguments(), ["capabilities --json", "diagnose --json"]) } + func testReadinessUsesModernDaemonPathReportedByDiagnose() throws { + let harness = try AgentRunSmokeReadinessHarness( + diagnoseJSON: #"{"schemaVersion":1,"schemaID":"https://vifty.local/schemas/viftyctl-diagnose.schema.json","state":"ready","modelIdentifier":"MacBookPro18,1","isAppleSilicon":true,"isMacBookPro":true,"recommendedAgentAction":"requestCooling","recommendedRecoveryAction":"none","safeToRequestCooling":true,"daemonControlPathReady":true,"manualControlActive":false,"fanCount":2,"controllableFanCount":2,"temperatureSensorCount":6,"thermalPressure":"nominal","failedCheckIDs":[],"coolingBlockerIDs":[],"appPreferences":{"startupMode":"Auto","startupModeSource":"persisted","readError":null},"daemonRuntime":{"installedDaemonPath":"__INSTALLED_DAEMON_PATH__"}}"#, + useDiagnosedDaemonPath: true + ) + + let result = try harness.runReadiness([ + "--viftyctl", harness.viftyctlURL.path, + "--expected-daemon", harness.expectedDaemonURL.path, + "--require-daemon-match", + "--json" + ]) + + XCTAssertEqual(result.exitCode, 0, result.stderr) + let summary = try XCTUnwrap(AgentRunSmokeReadinessHarness.parseJSON(result.stdout)) + let daemonRuntime = try XCTUnwrap(summary["daemonRuntime"] as? [String: Any]) + XCTAssertEqual(daemonRuntime["installedDaemonPath"] as? String, harness.installedDaemonURL.lastPathComponent) + XCTAssertEqual(daemonRuntime["installedDaemonPresent"] as? Bool, true) + XCTAssertEqual(daemonRuntime["matchesExpectedDaemon"] as? Bool, true) + XCTAssertEqual(daemonRuntime["matchRequired"] as? Bool, true) + XCTAssertEqual(try harness.loggedArguments(), ["capabilities --json", "diagnose --json"]) + } + func testReadinessBlocksFallbackCapabilitiesBeforeCoolingBoundary() throws { let harness = try AgentRunSmokeReadinessHarness( capabilitiesJSON: AgentRunSmokeReadinessHarness.capabilitiesJSON( @@ -344,6 +367,7 @@ private final class AgentRunSmokeReadinessHarness { private let capabilitiesExitCode: Int private let diagnoseJSON: String private let diagnoseExitCode: Int + private let useDiagnosedDaemonPath: Bool init( capabilitiesJSON: String = AgentRunSmokeReadinessHarness.capabilitiesJSON(), @@ -351,7 +375,8 @@ private final class AgentRunSmokeReadinessHarness { diagnoseJSON: String = AgentRunSmokeReadinessHarness.diagnoseJSON(), diagnoseExitCode: Int = 0, installedDaemonContents: String = "installed daemon", - expectedDaemonContents: String = "installed daemon" + expectedDaemonContents: String = "installed daemon", + useDiagnosedDaemonPath: Bool = false ) throws { repositoryRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) rootURL = FileManager.default.temporaryDirectory @@ -362,8 +387,9 @@ private final class AgentRunSmokeReadinessHarness { expectedDaemonURL = rootURL.appendingPathComponent("expected-daemon") self.capabilitiesJSON = capabilitiesJSON self.capabilitiesExitCode = capabilitiesExitCode - self.diagnoseJSON = diagnoseJSON + self.diagnoseJSON = diagnoseJSON.replacingOccurrences(of: "__INSTALLED_DAEMON_PATH__", with: installedDaemonURL.path) self.diagnoseExitCode = diagnoseExitCode + self.useDiagnosedDaemonPath = useDiagnosedDaemonPath try FileManager.default.createDirectory( at: viftyctlURL.deletingLastPathComponent(), @@ -384,15 +410,18 @@ private final class AgentRunSmokeReadinessHarness { process.executableURL = URL(fileURLWithPath: "/bin/bash") process.currentDirectoryURL = repositoryRoot process.arguments = [script.path] + arguments - process.environment = ProcessInfo.processInfo.environment.merging([ + var environment = ProcessInfo.processInfo.environment.merging([ "VIFTY_TEST_SHELL_FIXTURES": "1", "VIFTY_FAKE_LOG": logURL.path, "VIFTY_FAKE_CAPABILITIES_JSON": capabilitiesJSON, "VIFTY_FAKE_CAPABILITIES_EXIT": "\(capabilitiesExitCode)", "VIFTY_FAKE_DIAGNOSE_JSON": diagnoseJSON, - "VIFTY_FAKE_DIAGNOSE_EXIT": "\(diagnoseExitCode)", - "VIFTY_AGENT_RUN_SMOKE_INSTALLED_DAEMON_PATH": installedDaemonURL.path + "VIFTY_FAKE_DIAGNOSE_EXIT": "\(diagnoseExitCode)" ]) { _, new in new } + if !useDiagnosedDaemonPath { + environment["VIFTY_AGENT_RUN_SMOKE_INSTALLED_DAEMON_PATH"] = installedDaemonURL.path + } + process.environment = environment let stdout = Pipe() let stderr = Pipe() diff --git a/Tests/ViftyCoreTests/AgentWorkflowSupportTests.swift b/Tests/ViftyCoreTests/AgentWorkflowSupportTests.swift index 9408d3d0..f66eae22 100644 --- a/Tests/ViftyCoreTests/AgentWorkflowSupportTests.swift +++ b/Tests/ViftyCoreTests/AgentWorkflowSupportTests.swift @@ -30,7 +30,7 @@ final class AgentWorkflowSupportTests: XCTestCase { XCTAssertTrue(AgentWorkflowSupport.safeWorkloadCommandTemplates.contains { $0.id == "local-model-template" }) XCTAssertTrue(AgentWorkflowSupport.safeWorkloadCommandTemplates.contains { $0.id == "custom-workload-template" }) - let rule = AgentWorkflowSupport.agentRule() + let rule = ViftyAgentRule.rule() XCTAssertTrue(rule.contains("capabilities --json")) XCTAssertTrue(rule.contains("diagnose --json")) @@ -101,7 +101,7 @@ final class AgentWorkflowSupportTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o755)], ofItemAtPath: guardedRunURL.path) try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o755)], ofItemAtPath: swiftTestURL.path) - let rule = AgentWorkflowSupport.agentRule(bundleURL: appURL) + let rule = ViftyAgentRule.rule(bundleURL: appURL) XCTAssertTrue(rule.contains("'\(viftyCtlURL.path)' capabilities --json")) XCTAssertTrue(rule.contains("'\(viftyCtlURL.path)' diagnose --json")) @@ -129,7 +129,7 @@ final class AgentWorkflowSupportTests: XCTestCase { try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o755)], ofItemAtPath: guardedRunURL.path) try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o755)], ofItemAtPath: swiftTestURL.path) - let rule = AgentWorkflowSupport.agentRule(bundleURL: appURL) + let rule = ViftyAgentRule.rule(bundleURL: appURL) XCTAssertTrue(rule.contains("'\(viftyCtlURL.path.replacingOccurrences(of: "'", with: "'\\''"))' capabilities --json")) XCTAssertTrue(rule.contains("'\(viftyCtlURL.path.replacingOccurrences(of: "'", with: "'\\''"))' diagnose --json")) @@ -175,7 +175,7 @@ final class AgentWorkflowSupportTests: XCTestCase { let appURL = root.appendingPathComponent("Vifty.app", isDirectory: true) try FileManager.default.createDirectory(at: appURL, withIntermediateDirectories: true) - let rule = AgentWorkflowSupport.agentRule(bundleURL: appURL) + let rule = ViftyAgentRule.rule(bundleURL: appURL) XCTAssertTrue(rule.contains("'/Applications/Vifty.app/Contents/MacOS/viftyctl' capabilities --json")) XCTAssertTrue(rule.contains("'/Applications/Vifty.app/Contents/MacOS/viftyctl' diagnose --json")) @@ -213,51 +213,51 @@ final class AgentWorkflowSupportTests: XCTestCase { let explicitViftyCtlPrefix = "VIFTYCTL='\(appURL.appendingPathComponent("Contents/MacOS/viftyctl").path)' " XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(swiftTest, mode: .run, bundleURL: appURL), + ViftyAgentRule.workloadCommand(swiftTest, mode: .run, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("swift-test.sh").path)'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(swiftTest, mode: .preflight, bundleURL: appURL), + ViftyAgentRule.workloadCommand(swiftTest, mode: .preflight, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("guarded-run.sh").path)' '--preflight-only' 'test' '20m' '70' 'swift test' '--' 'swift' 'test'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(xcodeBuild, mode: .run, bundleURL: appURL), + ViftyAgentRule.workloadCommand(xcodeBuild, mode: .run, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("xcode-build.sh").path)'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(xcodeBuild, mode: .preflight, bundleURL: appURL), + ViftyAgentRule.workloadCommand(xcodeBuild, mode: .preflight, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("guarded-run.sh").path)' '--preflight-only' 'build' '30m' '75' 'xcodebuild build' '--' 'xcodebuild' 'build'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(goTest, mode: .run, bundleURL: appURL), + ViftyAgentRule.workloadCommand(goTest, mode: .run, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("go-test.sh").path)'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(goTest, mode: .preflight, bundleURL: appURL), + ViftyAgentRule.workloadCommand(goTest, mode: .preflight, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("guarded-run.sh").path)' '--preflight-only' 'test' '20m' '70' 'go test' '--' 'go' 'test'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(uvTest, mode: .run, bundleURL: appURL), + ViftyAgentRule.workloadCommand(uvTest, mode: .run, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("uv-test.sh").path)'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(uvTest, mode: .preflight, bundleURL: appURL), + ViftyAgentRule.workloadCommand(uvTest, mode: .preflight, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("guarded-run.sh").path)' '--preflight-only' 'test' '20m' '70' 'uv pytest' '--' 'uv' 'run' 'pytest'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(localModel, mode: .run, bundleURL: appURL), + ViftyAgentRule.workloadCommand(localModel, mode: .run, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("local-model.sh").path)' '--' './run-local-model.sh'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(localModel, mode: .preflight, bundleURL: appURL), + ViftyAgentRule.workloadCommand(localModel, mode: .preflight, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("guarded-run.sh").path)' '--preflight-only' 'localModel' '30m' '75' 'local model run' '--' './run-local-model.sh'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(customWorkload, mode: .run, bundleURL: appURL), + ViftyAgentRule.workloadCommand(customWorkload, mode: .run, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("custom-workload.sh").path)' '15m' '65' 'custom workload' '--' './scripts/smoke-test.sh'" ) XCTAssertEqual( - AgentWorkflowSupport.workloadCommand(customWorkload, mode: .preflight, bundleURL: appURL), + ViftyAgentRule.workloadCommand(customWorkload, mode: .preflight, bundleURL: appURL), "\(explicitViftyCtlPrefix)'\(wrappersURL.appendingPathComponent("guarded-run.sh").path)' '--preflight-only' 'custom' '15m' '65' 'custom workload' '--' './scripts/smoke-test.sh'" ) } diff --git a/Tests/ViftyCoreTests/AppPollingControllerTests.swift b/Tests/ViftyCoreTests/AppPollingControllerTests.swift index c091a943..dee63467 100644 --- a/Tests/ViftyCoreTests/AppPollingControllerTests.swift +++ b/Tests/ViftyCoreTests/AppPollingControllerTests.swift @@ -4,7 +4,7 @@ import XCTest @MainActor final class AppPollingControllerTests: XCTestCase { func testStartIsIdempotentAndStopCancelsTheLoop() async { - let sleeper = ManualPollingSleeper() + let sleeper = AppModelManualPollingSleeper() let controller = AppPollingController(sleeper: sleeper) let started = expectation(description: "initial operation") var initialRuns = 0 @@ -124,7 +124,7 @@ final class AppPollingControllerTests: XCTestCase { } func testIntervalProviderIsReadAgainAfterEveryPoll() async { - let sleeper = ManualPollingSleeper() + let sleeper = AppModelManualPollingSleeper() let controller = AppPollingController(sleeper: sleeper) let polled = expectation(description: "first repeat poll") var interval = Duration.seconds(10) @@ -184,46 +184,3 @@ private actor PollingGate { continuation = nil } } - -private actor ManualPollingSleeper: AppPollingSleeping { - private var requestedDurations: [Duration] = [] - private var durationWaiters: [CheckedContinuation] = [] - private var sleepWaiters: [CheckedContinuation] = [] - - func sleep(for duration: Duration) async throws { - if let waiter = durationWaiters.first { - durationWaiters.removeFirst() - waiter.resume(returning: duration) - } else { - requestedDurations.append(duration) - } - - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - sleepWaiters.append(continuation) - } - } onCancel: { - Task { await self.cancelAll() } - } - } - - func nextRequestedDuration() async -> Duration { - if !requestedDurations.isEmpty { - return requestedDurations.removeFirst() - } - return await withCheckedContinuation { continuation in - durationWaiters.append(continuation) - } - } - - func resumeNext() { - guard !sleepWaiters.isEmpty else { return } - sleepWaiters.removeFirst().resume() - } - - func cancelAll() { - let waiters = sleepWaiters - sleepWaiters.removeAll() - waiters.forEach { $0.resume(throwing: CancellationError()) } - } -} diff --git a/Tests/ViftyCoreTests/CodexUsageTests.swift b/Tests/ViftyCoreTests/CodexUsageTests.swift index 74307ce9..db8e04b0 100644 --- a/Tests/ViftyCoreTests/CodexUsageTests.swift +++ b/Tests/ViftyCoreTests/CodexUsageTests.swift @@ -2,6 +2,19 @@ import XCTest @testable import Vifty final class CodexUsageTests: XCTestCase { + func testReaderSelectsLastValidEventInDenseTail() throws { + let root = try temporaryDirectory() + let sessions = root.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + let event = "{\"timestamp\":\"2026-06-21T11:00:00Z\",\"payload\":{\"type\":\"token_count\",\"rate_limits\":{\"primary\":{\"used_percent\":21,\"resets_at\":1800003600,\"window_minutes\":300}}}}\n" + let newest = event.replacingOccurrences(of: "\"used_percent\":21", with: "\"used_percent\":42") + try (String(repeating: event, count: 3000) + newest).write( + to: sessions.appendingPathComponent("dense.jsonl"), atomically: true, encoding: .utf8 + ) + let reader = CodexUsageReader(codexHome: root) + XCTAssertEqual(try XCTUnwrap(reader.read()).usedPercent, 42) + } + func testReaderParsesLatestPrimaryRateLimitFromCodexSessions() throws { let root = try temporaryDirectory() let sessions = root.appendingPathComponent("sessions", isDirectory: true) diff --git a/Tests/ViftyCoreTests/DaemonLifecycleCoordinatorTests.swift b/Tests/ViftyCoreTests/DaemonLifecycleCoordinatorTests.swift index 1e784d10..82474e3d 100644 --- a/Tests/ViftyCoreTests/DaemonLifecycleCoordinatorTests.swift +++ b/Tests/ViftyCoreTests/DaemonLifecycleCoordinatorTests.swift @@ -87,6 +87,28 @@ final class DaemonLifecycleCoordinatorTests: XCTestCase { } } + func testPreparedTokenSurvivesJSONDateRoundTrip() async throws { + let fixture = makeFixture() + fixture.state.date = Date(timeIntervalSince1970: 1_000.1234567) + + let report = try await fixture.coordinator.prepare( + operation: .repair, + helperSHA256: fixture.state.helperHash + ) + let token = try XCTUnwrap(report.token) + let request = HelperMaintenanceAuthorizationRequest(operation: .repair, token: token) + let roundTripped = try XCTUnwrap( + XPCHelperMaintenanceCoding.decodeAuthorizationRequest( + XPCHelperMaintenanceCoding.encode(request) + ) + ) + + XCTAssertEqual(roundTripped, request) + fixture.state.date = token.issuedAt.addingTimeInterval(0.001) + let authorization = try await fixture.coordinator.consume(roundTripped) + XCTAssertTrue(authorization.authorized) + } + func testConcurrentConsumeReservesTokenBeforeAwaitAndAuthorizesExactlyOnce() async throws { let fixture = makeFixture() let report = try await fixture.coordinator.prepare( diff --git a/Tests/ViftyCoreTests/DaemonTerminationSignalGateTests.swift b/Tests/ViftyCoreTests/DaemonTerminationSignalGateTests.swift index 6b1dc4d9..68ad166a 100644 --- a/Tests/ViftyCoreTests/DaemonTerminationSignalGateTests.swift +++ b/Tests/ViftyCoreTests/DaemonTerminationSignalGateTests.swift @@ -42,6 +42,24 @@ final class DaemonTerminationSignalGateTests: XCTestCase { XCTAssertLessThan(ignoreRange.lowerBound, bootstrapRange.lowerBound) XCTAssertLessThan(sourceRange.lowerBound, bootstrapRange.lowerBound) + XCTAssertTrue(source.contains("DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)")) + } + + func testDaemonMainKeepsTheMachListenerAliveWithoutBlockingAsyncMainQueue() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appendingPathComponent("Sources/ViftyDaemon/main.swift"), + encoding: .utf8 + ) + + XCTAssertTrue(source.contains("while !Task.isCancelled"), "The async daemon main must stay suspended after registering its Mach listener.") + XCTAssertTrue(source.contains("Task.sleep"), "The async daemon main must keep its task alive without blocking the main queue.") + XCTAssertTrue(source.contains("Task.detached"), "SIGTERM cleanup must not inherit an unsafe actor/queue context.") + XCTAssertFalse(source.contains("dispatchMain()"), "dispatchMain cannot be called from Swift async main's main-queue block on macOS 27.") + XCTAssertFalse(source.contains("RunLoop.main.run()"), "RunLoop.main.run() returns on the current launchd/XPC path.") } } diff --git a/Tests/ViftyCoreTests/HelperLifecycleScriptTests.swift b/Tests/ViftyCoreTests/HelperLifecycleScriptTests.swift index ca4156e8..d6dc2461 100644 --- a/Tests/ViftyCoreTests/HelperLifecycleScriptTests.swift +++ b/Tests/ViftyCoreTests/HelperLifecycleScriptTests.swift @@ -3,6 +3,75 @@ import Foundation import XCTest final class HelperLifecycleScriptTests: XCTestCase { + func testControlAppIsRejectedForRepairBeforeInvokingFixture() throws { + let fixture = try LifecycleFixture() + defer { fixture.remove() } + let controlApp = try fixture.cloneApp(in: "control-source") + + let result = try fixture.runLifecycle( + operation: "repair", + dryRun: false, + controlApp: controlApp + ) + + XCTAssertEqual(result.exitCode, 64, result.output) + XCTAssertTrue(result.output.contains("--control-app is only valid for uninstall or repair replacement prepare."), result.output) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.forbiddenInvocationLog.path)) + } + + func testControlAppIsAcceptedForReplacementPhaseAndKeepsTargetLedgerBound() throws { + let fixture = try LifecycleFixture() + defer { fixture.remove() } + let controlApp = try fixture.cloneApp(in: "control-source") + try fixture.installControlSourceMarker(in: controlApp) + + let result = try fixture.runLifecycle( + operation: "repair", + dryRun: false, + controlApp: controlApp, + replacementPhase: "prepare", + replacementDestination: fixture.app, + replacementCandidate: controlApp, + replacementPrevious: fixture.app + ) + + XCTAssertEqual(result.exitCode, 0, result.output) + if result.output.contains("--control-app is only valid") { + XCTFail("unexpected control-app rejection: \(result.output)") + } + let invocations = try fixture.readInvocations() + let invocationText = invocations.joined(separator: "\n") + XCTAssertTrue(invocations.contains("control source viftyctl"), invocationText) + XCTAssertTrue(invocations.contains("control source Vifty"), invocationText) + XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.replacementRecord.path)) + } + + func testSymlinkedControlAppFailsClosedBeforeInvokingFixture() throws { + let fixture = try LifecycleFixture() + defer { fixture.remove() } + let controlLink = fixture.root + .appendingPathComponent("control-link", isDirectory: true) + .appendingPathComponent("Vifty.app", isDirectory: true) + try FileManager.default.createDirectory( + at: controlLink.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + at: controlLink, + withDestinationURL: fixture.app + ) + + let result = try fixture.runLifecycle( + operation: "uninstall", + dryRun: false, + controlApp: controlLink + ) + + XCTAssertEqual(result.exitCode, 66, result.output) + XCTAssertTrue(result.output.contains("control app must be a real Vifty.app directory"), result.output) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.forbiddenInvocationLog.path)) + } + func testRepairDryRunRecordsRequiredOrderButRemainsBlocked() throws { let fixture = try LifecycleFixture() defer { fixture.remove() } @@ -109,6 +178,22 @@ final class HelperLifecycleScriptTests: XCTestCase { XCTAssertTrue(try fixture.workerScratchDirectories().isEmpty) } + func testLegacyReferenceDateMaintenanceReportIsNormalizedBeforeAuthorization() throws { + let fixture = try LifecycleFixture() + defer { fixture.remove() } + + let result = try fixture.runLifecycle( + operation: "repair", + dryRun: false, + extraEnvironment: [ + "VIFTY_FIXTURE_REFERENCE_DATE_MAINTENANCE": "1", + "VIFTY_FIXTURE_REQUIRE_EPOCH_MAINTENANCE_DATES": "1" + ] + ) + + XCTAssertEqual(result.exitCode, 0, result.output) + } + func testSuccessfulUninstallNeverRegistersAndPreservesRecoveryState() throws { let fixture = try LifecycleFixture() defer { fixture.remove() } @@ -244,6 +329,41 @@ final class HelperLifecycleScriptTests: XCTestCase { XCTAssertEqual(privileged["status"] as? String, "blocked") } + func testPinnedPublicHelperUninstallKeepsServiceFrozenThroughUnregister() throws { + let fixture = try LifecycleFixture() + defer { fixture.remove() } + let script = try fixture.recoveryLifecycleScript() + let result = try fixture.runLifecycle( + operation: "uninstall", dryRun: false, executable: script, + extraEnvironment: ["VIFTY_FIXTURE_PREPARE_FAILURE": "1"] + ) + XCTAssertEqual(result.exitCode, 0, result.output) + XCTAssertEqual(try fixture.readInvocations(), [ + "viftyctl prepare uninstall", "viftyctl cancel", + "launchctl disable", "launchctl bootout", + "ViftyHelper authorizeLegacyTeardown uninstall", + "Vifty unregister-legacy uninstall" + ]) + } + + func testPinnedPublicHelperCannotDowngradeRepairOrUnsafeAuto() throws { + for operation in ["repair", "uninstall"] { + let fixture = try LifecycleFixture() + defer { fixture.remove() } + let script = try fixture.recoveryLifecycleScript() + let result = try fixture.runLifecycle( + operation: operation, dryRun: false, executable: script, + extraEnvironment: [ + "VIFTY_FIXTURE_PREPARE_FAILURE": "1", + "VIFTY_FIXTURE_LEGACY_UNSAFE": operation == "uninstall" ? "1" : "0" + ] + ) + XCTAssertEqual(result.exitCode, 75, result.output) + XCTAssertFalse(try fixture.readInvocations().contains("Vifty unregister-legacy uninstall")) + XCTAssertTrue(fixture.legacyFiles.allSatisfy { FileManager.default.fileExists(atPath: $0.path) }) + } + } + func testOnlyExplicitProtocolMismatchCanEnterOfflineRecovery() throws { for environment in [ ["VIFTY_FIXTURE_PREPARE_FAILURE": "1"], @@ -983,6 +1103,8 @@ final class HelperLifecycleScriptTests: XCTestCase { defer { fixture.remove() } let firstCandidate = try fixture.cloneApp(in: "candidate-ledger-first") let secondCandidate = try fixture.cloneApp(in: "candidate-ledger-second") + let controlApp = try fixture.cloneApp(in: "control-source") + try fixture.installControlSourceMarker(in: controlApp) XCTAssertEqual(try fixture.runLifecycle( operation: "repair", dryRun: false, replacementPhase: "prepare", @@ -1012,8 +1134,18 @@ final class HelperLifecycleScriptTests: XCTestCase { XCTAssertEqual(nextPrepare.exitCode, 0, nextPrepare.output) XCTAssertEqual(try fixture.readReplacementRecord()["replacementTransactionID"] as? String, nextID) - let uninstall = try fixture.runLifecycle(operation: "uninstall", dryRun: false) + let uninstall = try fixture.runLifecycle( + operation: "uninstall", + dryRun: false, + controlApp: controlApp + ) XCTAssertEqual(uninstall.exitCode, 0, uninstall.output) + let uninstallInvocations = try fixture.readInvocations() + XCTAssertTrue(uninstallInvocations.contains("control source viftyctl"), uninstall.output) + XCTAssertTrue(uninstallInvocations.contains("control source Vifty"), uninstall.output) + let uninstallRecord = try fixture.readRecord() + XCTAssertEqual(uninstallRecord["app"] as? String, fixture.app.path) + XCTAssertEqual(uninstallRecord["controlApp"] as? String, controlApp.path) XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.replacementRecord.path)) } @@ -1722,6 +1854,21 @@ private final class LifecycleFixture { ) } + func installControlSourceMarker(in bundle: URL) throws { + for executable in ["viftyctl", "Vifty"] { + let url = bundle.appendingPathComponent("Contents/MacOS/\(executable)") + let original = try String(contentsOf: url, encoding: .utf8) + let marker = "printf 'control source \(executable)\\n' >> \"${VIFTY_FIXTURE_INVOCATION_LOG}\"\n" + let instrumented = original.replacingOccurrences( + of: "#!/bin/bash\n", + with: "#!/bin/bash\n\(marker)", + options: [], + range: original.startIndex.. Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/stat") @@ -1765,10 +1912,22 @@ private final class LifecycleFixture { process.waitUntilExit() } + func recoveryLifecycleScript() throws -> URL { + let source = repositoryRoot.appendingPathComponent("scripts/vifty-helper-lifecycle.sh") + let copy = root.appendingPathComponent("recovery-lifecycle.sh") + let text = try String(contentsOf: source, encoding: .utf8).replacingOccurrences( + of: "4c467d99f7e59c2727f0e1a9b13de81772741d269b560ce6ca9fb605782f0d0f", + with: try sha256(app.appendingPathComponent("Contents/MacOS/ViftyHelper")) + ) + try text.write(to: copy, atomically: true, encoding: .utf8) + return copy + } + func runLifecycle( operation: String, dryRun: Bool, maintenanceReport: URL? = nil, + controlApp: URL? = nil, replacementPhase: String? = nil, replacementDestination: URL? = nil, replacementResult: String? = nil, @@ -1785,6 +1944,9 @@ private final class LifecycleFixture { "--app", app.path, "--record", record.path ] + if let controlApp { + arguments.append(contentsOf: ["--control-app", controlApp.path]) + } if dryRun { arguments.append("--dry-run") } if let maintenanceReport { arguments.append(contentsOf: ["--maintenance-report", maintenanceReport.path]) @@ -1849,7 +2011,9 @@ private final class LifecycleFixture { "VIFTY_FIXTURE_PARENT_START_ID", "VIFTY_FIXTURE_PREPARE_FAILURE", "VIFTY_FIXTURE_SAFETY_BLOCK", - "VIFTY_FIXTURE_MALFORMED_BLOCK" + "VIFTY_FIXTURE_MALFORMED_BLOCK", + "VIFTY_FIXTURE_REFERENCE_DATE_MAINTENANCE", + "VIFTY_FIXTURE_REQUIRE_EPOCH_MAINTENANCE_DATES" ,"VIFTY_FIXTURE_SWAP_BEFORE_REGISTER" ,"VIFTY_FIXTURE_SWAP_BEFORE_ENABLE" ,"VIFTY_FIXTURE_ALTERNATE_APP" @@ -1984,8 +2148,15 @@ private final class LifecycleFixture { exit 75 fi helper_sha="$(/usr/bin/shasum -a 256 "$(/usr/bin/dirname "$0")/ViftyHelper" | /usr/bin/awk '{print $1}')" + issued_at=1000 + expires_at=1030 + if [[ "${VIFTY_FIXTURE_REFERENCE_DATE_MAINTENANCE:-0}" == "1" ]]; then + now="$(/bin/date +%s)" + issued_at="$((now - 978307200))" + expires_at="$((now + 30 - 978307200))" + fi cat <> '\(existingCtlLog.path)'\npayload=$(printf '%s' '\(encoded)' | /usr/bin/base64 --decode)\ndaemon_path=$(cd \"$(dirname \"$0\")\" && pwd)/ViftyDaemon\nprintf '%s' \"$payload\" | /usr/bin/sed \"s|__VIFTY_EXPECTED_DAEMON_PATH__|$daemon_path|g\"\n\(removeCandidateDaemonAfterDiagnose ? "/bin/rm -f '\(buildApp.appendingPathComponent("Contents/MacOS/ViftyDaemon").path)'" : ":")\nexit \(existingCtlExitCode)\n", to: existingCtl ) - try Self.writeExecutable("#!/bin/bash\nexit 0\n", to: buildApp.appendingPathComponent("Contents/MacOS/Vifty")) + try Self.writeExecutable( + """ + #!/bin/bash + printf '{"action":"register","state":"enabled","complete":true,"operatorActionRequired":false,"maintenanceAuthorized":false,"tokenID":null}\n' + exit 0 + """, + to: buildApp.appendingPathComponent("Contents/MacOS/Vifty") + ) try Self.writeExecutable("#!/bin/bash\nexit 0\n", to: buildApp.appendingPathComponent("Contents/MacOS/ViftyDaemon")) let resolvedCandidateReport = candidateReport ?? (report.isPublishedV132Report ? .candidateObservesLegacyV132 : report) let encodedCandidate = Data(resolvedCandidateReport.json( @@ -1310,7 +1337,7 @@ private final class InstallReplacementFixture { withIntermediateDirectories: true ) try Self.writeExecutable( - "#!/bin/bash\nexit 0\n", + "#!/bin/bash\nprintf '{\"action\":\"register\",\"state\":\"enabled\",\"complete\":true,\"operatorActionRequired\":false,\"maintenanceAuthorized\":false,\"tokenID\":null}\\n'\n", to: fallbackApp.appendingPathComponent("Contents/MacOS/Vifty") ) try Self.writeExecutable( diff --git a/Tests/ViftyCoreTests/LocalFanHelperClientTests.swift b/Tests/ViftyCoreTests/LocalFanHelperClientTests.swift index 0991ba0b..4d6f244a 100644 --- a/Tests/ViftyCoreTests/LocalFanHelperClientTests.swift +++ b/Tests/ViftyCoreTests/LocalFanHelperClientTests.swift @@ -24,6 +24,53 @@ final class LocalFanHelperClientTests: XCTestCase { XCTAssertTrue(receipt.warnings.isEmpty) } + func testTransientTargetReadbackIsRetriedWithoutForceTest() throws { + let smc = FakeSMCConnection(values: [ + "F0Md": Self.modeValue(mode: 0), + "F0Tg": Self.targetValue(rpm: 1_400) + ]) + smc.ignoreNextWrite(to: "F0Tg") + let clock = ManualMonotonicClock() + let client = LocalFanHelperClient( + smcFactory: { smc }, + unlockRetryIntervalSeconds: 0.1, + monotonicNow: { clock.now }, + sleep: { clock.advance(by: $0) } + ) + + let receipt = try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_200)), + fan: Self.fan() + ) + + XCTAssertEqual(receipt.observedTargetRPM, 3_200) + XCTAssertEqual(smc.writes.map(\.key), ["F0Md", "F0Tg", "F0Tg"]) + XCTAssertEqual(clock.sleepCalls, [0.1]) + } + + func testTransientAutoReadbackIsRetriedWithoutForceTest() throws { + let smc = FakeSMCConnection(values: [ + "F0Md": Self.modeValue(mode: 1), + "F0Tg": Self.targetValue(rpm: 2_400) + ]) + smc.ignoreNextWrite(to: "F0Md") + let clock = ManualMonotonicClock() + let client = LocalFanHelperClient( + smcFactory: { smc }, + unlockRetryIntervalSeconds: 0.1, + targetReadbackTimeoutSeconds: 0.2, + monotonicNow: { clock.now }, + sleep: { clock.advance(by: $0) } + ) + + let receipt = try client.restoreAuto(fan: Self.fan()) + + XCTAssertEqual(receipt.observedMode, .automatic) + XCTAssertTrue(receipt.recoveryConfirmed) + XCTAssertEqual(smc.writes.map(\.key), ["F0Md", "F0Md", "F0Tg"]) + XCTAssertEqual(clock.sleepCalls, [0.1]) + } + func testFixedRPMUsesLowercaseModeKeyWhenUppercaseModeKeyIsMissing() throws { let smc = FakeSMCConnection(values: [ "F0md": Self.modeValue(key: "F0md", mode: 0), @@ -41,6 +88,96 @@ final class LocalFanHelperClientTests: XCTestCase { XCTAssertEqual(receipt.observedTargetRPM, 3_200) } + func testTargetReadbackMismatchUsesGuardedForceTestRetry() throws { + let smc = FakeSMCConnection( + values: Self.controlValues(), + targetWritesRequireForceTest: true + ) + let client = LocalFanHelperClient(smcFactory: { smc }, unlockRetryIntervalSeconds: 0) + + let receipt = try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_200)), + fan: Self.fan() + ) + + XCTAssertEqual( + smc.writes.map(\.key), + ["F0Md", "F0Tg", "F0Md", "Ftst", "F0Md", "F0Tg"] + ) + XCTAssertEqual(receipt.observedMode, .forced) + XCTAssertEqual(receipt.observedTargetRPM, 3_200) + XCTAssertFalse(receipt.forceTestDisabled) + } + + func testTargetFallbackWaitsForForceTestHandoffBeforeRetryingMode() throws { + let smc = FakeSMCConnection( + values: Self.controlValues(), + targetWritesRequireForceTest: true + ) + let clock = ManualMonotonicClock() + let client = LocalFanHelperClient( + smcFactory: { smc }, + unlockRetryIntervalSeconds: 0.1, + forceTestSettleSeconds: 0.2, + targetReadbackTimeoutSeconds: 0, + monotonicNow: { clock.now }, + sleep: { clock.advance(by: $0) } + ) + + _ = try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_200)), + fan: Self.fan() + ) + + XCTAssertEqual(clock.sleepCalls, [0.1, 0.1]) + XCTAssertEqual( + smc.writes.map(\.key), + ["F0Md", "F0Tg", "F0Md", "Ftst", "F0Md", "F0Md", "F0Md", "F0Tg"] + ) + } + + func testAlreadyEnabledForceTestRemainsEnabledAcrossManualApply() throws { + let smc = FakeSMCConnection(values: Self.controlValues(forceTest: 1)) + let client = LocalFanHelperClient(smcFactory: { smc }, unlockRetryIntervalSeconds: 0) + + let receipt = try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_200)), + fan: Self.fan() + ) + + XCTAssertEqual(smc.writes.map(\.key), ["F0Md", "F0Tg"]) + XCTAssertEqual(receipt.observedMode, .forced) + XCTAssertEqual(receipt.observedTargetRPM, 3_200) + XCTAssertFalse(receipt.forceTestDisabled) + } + + func testModeReadbackMismatchWithoutFtstRetriesDirectly() throws { + let smc = FakeSMCConnection(values: [ + "F0Md": Self.modeValue(key: "F0Md", mode: 0), + "F0Tg": Self.targetValue(rpm: 1_400) + ]) + smc.ignoreNextWrite(to: "F0Md") + smc.ignoreNextWrite(to: "F0Md") + let clock = ManualMonotonicClock() + let client = LocalFanHelperClient( + smcFactory: { smc }, + unlockTimeoutSeconds: 0.2, + unlockRetryIntervalSeconds: 0.1, + monotonicNow: { clock.now }, + sleep: { clock.advance(by: $0) } + ) + + let receipt = try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_200)), + fan: Self.fan(hardwareModeKey: "F0Md") + ) + + XCTAssertEqual(smc.writes.map(\.key), ["F0Md", "F0Md", "F0Md", "F0Tg"]) + XCTAssertEqual(clock.sleepCalls, [0.1]) + XCTAssertEqual(receipt.observedMode, .forced) + XCTAssertEqual(receipt.observedTargetRPM, 3_200) + } + func testUnsupportedTargetLayoutFailsPreflightWithZeroWrites() { let smc = FakeSMCConnection(values: [ "F0Md": Self.modeValue(mode: 0), @@ -102,7 +239,10 @@ final class LocalFanHelperClientTests: XCTestCase { func testTargetFailureAfterForcedAttemptsCompleteCleanupAndReportsConfirmedRecovery() { let smc = FakeSMCConnection(values: Self.controlValues(mode: 0, targetRPM: 1_800)) smc.failNextWrite(to: "F0Tg", with: TestFailure("target write failed")) - let client = LocalFanHelperClient(smcFactory: { smc }) + let client = LocalFanHelperClient( + smcFactory: { smc }, + targetReadbackTimeoutSeconds: 0 + ) XCTAssertThrowsError( try client.apply( @@ -131,9 +271,15 @@ final class LocalFanHelperClientTests: XCTestCase { } func testFixedReadbackMismatchFailsAndRunsCleanup() { - let smc = FakeSMCConnection(values: Self.controlValues(mode: 0)) - smc.ignoreNextWrite(to: "F0Md") - let client = LocalFanHelperClient(smcFactory: { smc }) + let smc = FakeSMCConnection(values: [ + "F0Md": Self.modeValue(mode: 0), + "F0Tg": Self.targetValue(rpm: 1_400) + ]) + smc.ignoreNextWrite(to: "F0Tg") + let client = LocalFanHelperClient( + smcFactory: { smc }, + targetReadbackTimeoutSeconds: 0 + ) XCTAssertThrowsError( try client.apply( @@ -145,18 +291,53 @@ final class LocalFanHelperClientTests: XCTestCase { return XCTFail("Expected FanMutationError, got \(error)") } XCTAssertEqual(mutation.code, .readbackMismatch) - XCTAssertTrue(mutation.primaryError.contains("expected Forced at 3600 RPM")) + XCTAssertTrue(mutation.primaryError.contains("Fan target write was not confirmed")) XCTAssertTrue(mutation.receipt.recoveryConfirmed) XCTAssertEqual(mutation.receipt.observedMode, .automatic) } XCTAssertEqual( smc.writes.map(\.key), - ["F0Md", "F0Tg", "F0Md", "F0Tg", "Ftst"] + ["F0Md", "F0Tg", "F0Md", "F0Tg"] ) } - func testProtectedModeUnlockDisablesForceTestAndConfirmsReadback() throws { + func testSilentlyIgnoredManualModeWriteUsesGuardedUnlock() throws { + let smc = FakeSMCConnection(values: Self.controlValues(mode: 0)) + smc.ignoreNextWrite(to: "F0Md") + let client = LocalFanHelperClient(smcFactory: { smc }, unlockRetryIntervalSeconds: 0) + + let receipt = try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_600)), fan: Self.fan() + ) + + XCTAssertEqual(smc.writes.map(\.key), ["F0Md", "Ftst", "F0Md", "F0Tg"]) + XCTAssertEqual(receipt.observedMode, .forced) + XCTAssertEqual(receipt.observedTargetRPM, 3_600) + XCTAssertFalse(receipt.forceTestDisabled) + } + + func testIgnoredUnlockFailsAndRestoresAutoWithoutWritingRequestedTarget() { + let smc = FakeSMCConnection(values: Self.controlValues(mode: 0)) + smc.ignoreNextWrite(to: "F0Md") + smc.ignoreNextWrite(to: "F0Md") + let client = LocalFanHelperClient(smcFactory: { smc }, unlockRetryIntervalSeconds: 0) + + XCTAssertThrowsError(try client.apply( + FanCommand(fanID: 0, mode: .fixedRPM(3_600)), fan: Self.fan() + )) { error in + guard let mutation = error as? FanMutationError else { + return XCTFail("Expected FanMutationError, got \(error)") + } + XCTAssertTrue(mutation.receipt.recoveryConfirmed) + XCTAssertTrue(mutation.receipt.forceTestDisabled) + XCTAssertEqual(mutation.receipt.observedMode, .automatic) + } + XCTAssertEqual(smc.writes.map(\.key), ["F0Md", "Ftst", "F0Md", "F0Md", "F0Tg", "Ftst"]) + XCTAssertFalse(smc.writes.contains { $0.key == "F0Tg" && $0.bytes == SMCDecoding.encodeFPE2(3_600) }) + } + + func testProtectedModeUnlockKeepsForceTestAndConfirmsReadback() throws { let smc = FakeSMCConnection(values: Self.controlValues(mode: 3)) smc.failNextWrite(to: "F0Md", with: TestFailure("protected")) let client = LocalFanHelperClient(smcFactory: { smc }, unlockRetryIntervalSeconds: 0) @@ -168,12 +349,11 @@ final class LocalFanHelperClientTests: XCTestCase { XCTAssertEqual( smc.writes.map(\.key), - ["F0Md", "Ftst", "F0Md", "F0Tg", "Ftst"] + ["F0Md", "Ftst", "F0Md", "F0Tg"] ) XCTAssertEqual(smc.writes[1].bytes, [1]) - XCTAssertEqual(smc.writes[4].bytes, [0]) XCTAssertEqual(receipt.observedMode, .forced) - XCTAssertTrue(receipt.forceTestDisabled) + XCTAssertFalse(receipt.forceTestDisabled) } func testUnlockTimeoutAndCleanupFailureReturnRecoveryUnconfirmedWithoutWallTime() { @@ -219,7 +399,10 @@ final class LocalFanHelperClientTests: XCTestCase { smc.failNextWrite(to: "F0Tg", with: TestFailure("cleanup target failure")) smc.succeedNextWrite(to: "F0Md") smc.failNextWrite(to: "F0Md", with: TestFailure("cleanup auto failure")) - let client = LocalFanHelperClient(smcFactory: { smc }) + let client = LocalFanHelperClient( + smcFactory: { smc }, + targetReadbackTimeoutSeconds: 0 + ) XCTAssertThrowsError( try client.apply( @@ -479,13 +662,18 @@ private final class FakeSMCConnection: SMCConnection, @unchecked Sendable { private let lock = NSLock() private var values: [String: SMCValue] + private let targetWritesRequireForceTest: Bool private var queuedWriteBehaviors: [String: [WriteBehavior]] = [:] private var recordedReads: [String] = [] private var recordedWrites: [Write] = [] private var recordedEvents: [Event] = [] - init(values: [String: SMCValue]) { + init( + values: [String: SMCValue], + targetWritesRequireForceTest: Bool = false + ) { self.values = values + self.targetWritesRequireForceTest = targetWritesRequireForceTest } var writes: [Write] { @@ -545,6 +733,12 @@ private final class FakeSMCConnection: SMCConnection, @unchecked Sendable { return } } + if targetWritesRequireForceTest, + key.hasSuffix("Tg"), + let forceTest = values["Ftst"], + SMCDecoding.decodeFanControlByte(forceTest) == 0 { + return + } values[key] = SMCValue(key: key, dataType: dataType, bytes: bytes) } } diff --git a/Tests/ViftyCoreTests/MakefileTrustGateTests.swift b/Tests/ViftyCoreTests/MakefileTrustGateTests.swift index 8507bf82..807e35e1 100644 --- a/Tests/ViftyCoreTests/MakefileTrustGateTests.swift +++ b/Tests/ViftyCoreTests/MakefileTrustGateTests.swift @@ -2,6 +2,44 @@ import Foundation import XCTest final class MakefileTrustGateTests: XCTestCase { + func testVerifyChecksEveryShellScript() throws { + let makefile = try read("Makefile") + let verify = try XCTUnwrap(makefile.components(separatedBy: "verify: check-toolchain").last) + let recipe = try XCTUnwrap(verify.components(separatedBy: "\n").first { $0.hasPrefix("\t") }) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ViftyShellSyntax-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try ("verify:\n" + recipe + "\n").write( + to: root.appendingPathComponent("Makefile"), atomically: true, encoding: .utf8 + ) + let directories = ["scripts", "scripts/lib", "examples/viftyctl"] + for directory in directories { + let url = root.appendingPathComponent(directory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + try "true\n".write(to: url.appendingPathComponent("00-valid.sh"), atomically: true, encoding: .utf8) + } + func runGate() throws -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/make") + process.arguments = ["verify"] + process.currentDirectoryURL = root + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + _ = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return process.terminationStatus + } + XCTAssertEqual(try runGate(), 0) + for directory in directories { + let invalid = root.appendingPathComponent(directory).appendingPathComponent("99-invalid.sh") + try "if then\n".write(to: invalid, atomically: true, encoding: .utf8) + XCTAssertNotEqual(try runGate(), 0, "Skipped syntax error in \(directory)") + try FileManager.default.removeItem(at: invalid) + } + } + func testVerifyTargetRunsLocalTrustGates() throws { let makefile = try read("Makefile") @@ -102,7 +140,6 @@ final class MakefileTrustGateTests: XCTestCase { XCTAssertTrue(makefile.contains("InstallReplacementPreflightScriptTests")) XCTAssertTrue(makefile.contains("ReleaseManifestScriptTests")) XCTAssertTrue(makefile.contains("UIReviewEvidenceScriptTests")) - XCTAssertTrue(makefile.contains("/bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh")) XCTAssertTrue(makefile.contains("scripts/check-community-standards.sh")) XCTAssertTrue(makefile.contains("scripts/validate-release-metadata.sh --mode \"$(RELEASE_METADATA_MODE)\"")) let contractViolations = warningContractViolations(in: makefile) @@ -161,6 +198,15 @@ final class MakefileTrustGateTests: XCTestCase { XCTAssertTrue(makefile.contains("Identifier=tech.reidar.vifty.ctl")) } + func testInstallBuildsTheCurrentAppBeforeCopyingIt() throws { + let makefile = try read("Makefile") + + XCTAssertTrue( + makefile.contains("install: check-toolchain app ## Build and install to /Applications"), + "make install must rebuild the current app before the installer copies .build/Vifty.app." + ) + } + func testVerifyTargetIsListedAsPhonyAndHelpVisible() throws { let makefile = try read("Makefile") diff --git a/Tests/ViftyCoreTests/ManualSmokeReadinessScriptTests.swift b/Tests/ViftyCoreTests/ManualSmokeReadinessScriptTests.swift index 15b17ccb..635a082b 100644 --- a/Tests/ViftyCoreTests/ManualSmokeReadinessScriptTests.swift +++ b/Tests/ViftyCoreTests/ManualSmokeReadinessScriptTests.swift @@ -17,6 +17,29 @@ final class ManualSmokeReadinessScriptTests: XCTestCase { }) } + func testReadinessUsesModernDaemonPathReportedByDiagnose() throws { + let harness = try ManualSmokeReadinessHarness( + diagnoseJSON: #"{"state":"ready","modelIdentifier":"MacBookPro18,1","isAppleSilicon":true,"isMacBookPro":true,"recommendedAgentAction":"requestCooling","recommendedRecoveryAction":"none","safeToRequestCooling":true,"daemonControlPathReady":true,"manualControlActive":false,"fanCount":2,"controllableFanCount":2,"temperatureSensorCount":6,"thermalPressure":"nominal","failedCheckIDs":[],"coolingBlockerIDs":[],"appPreferences":{"startupMode":"Auto","startupModeSource":"persisted","readError":null},"daemonRuntime":{"installedDaemonPath":"__INSTALLED_DAEMON_PATH__"}}"#, + useDiagnosedDaemonPath: true + ) + + let result = try harness.runReadiness([ + "--viftyctl", harness.viftyctlURL.path, + "--expected-daemon", harness.expectedDaemonURL.path, + "--require-daemon-match", + "--json" + ]) + + XCTAssertEqual(result.exitCode, 0, result.stderr) + let summary = try XCTUnwrap(ManualSmokeReadinessHarness.parseJSON(result.stdout)) + let daemonRuntime = try XCTUnwrap(summary["daemonRuntime"] as? [String: Any]) + XCTAssertEqual(daemonRuntime["installedDaemonPath"] as? String, harness.installedDaemonURL.path) + XCTAssertEqual(daemonRuntime["installedDaemonPresent"] as? Bool, true) + XCTAssertEqual(daemonRuntime["matchesExpectedDaemon"] as? Bool, true) + XCTAssertEqual(daemonRuntime["matchRequired"] as? Bool, true) + XCTAssertEqual(try harness.loggedArguments(), ["diagnose --json"]) + } + func testReadinessBlocksWhenManualControlIsActive() throws { let harness = try ManualSmokeReadinessHarness( diagnoseJSON: #"{"state":"degraded","modelIdentifier":"MacBookPro18,1","isAppleSilicon":true,"isMacBookPro":true,"recommendedAgentAction":"restoreAutoBeforeRequestingCooling","recommendedRecoveryAction":"restoreAutoBeforeRetry","safeToRequestCooling":false,"daemonControlPathReady":true,"manualControlActive":true,"fanCount":2,"controllableFanCount":2,"temperatureSensorCount":6,"thermalPressure":"nominal","failedCheckIDs":["manualControlClear"],"coolingBlockerIDs":["manualControlClear"],"appPreferences":{"startupMode":"Curve","startupModeSource":"persisted","readError":null}}"# @@ -237,12 +260,14 @@ private final class ManualSmokeReadinessHarness { let expectedDaemonURL: URL private let diagnoseJSON: String private let diagnoseExitCode: Int + private let useDiagnosedDaemonPath: Bool init( diagnoseJSON: String = #"{"state":"ready","modelIdentifier":"MacBookPro18,1","isAppleSilicon":true,"isMacBookPro":true,"recommendedAgentAction":"requestCooling","recommendedRecoveryAction":"none","safeToRequestCooling":true,"daemonControlPathReady":true,"manualControlActive":false,"fanCount":2,"controllableFanCount":2,"temperatureSensorCount":6,"thermalPressure":"nominal","failedCheckIDs":[],"coolingBlockerIDs":[],"appPreferences":{"startupMode":"Auto","startupModeSource":"persisted","readError":null}}"#, diagnoseExitCode: Int = 0, installedDaemonContents: String = "installed daemon", - expectedDaemonContents: String = "installed daemon" + expectedDaemonContents: String = "installed daemon", + useDiagnosedDaemonPath: Bool = false ) throws { repositoryRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) rootURL = FileManager.default.temporaryDirectory @@ -251,8 +276,9 @@ private final class ManualSmokeReadinessHarness { logURL = rootURL.appendingPathComponent("viftyctl.log") installedDaemonURL = rootURL.appendingPathComponent("installed-daemon") expectedDaemonURL = rootURL.appendingPathComponent("expected-daemon") - self.diagnoseJSON = diagnoseJSON + self.diagnoseJSON = diagnoseJSON.replacingOccurrences(of: "__INSTALLED_DAEMON_PATH__", with: installedDaemonURL.path) self.diagnoseExitCode = diagnoseExitCode + self.useDiagnosedDaemonPath = useDiagnosedDaemonPath try FileManager.default.createDirectory( at: viftyctlURL.deletingLastPathComponent(), @@ -273,13 +299,16 @@ private final class ManualSmokeReadinessHarness { process.executableURL = URL(fileURLWithPath: "/bin/bash") process.currentDirectoryURL = repositoryRoot process.arguments = [script.path] + arguments - process.environment = ProcessInfo.processInfo.environment.merging([ + var environment = ProcessInfo.processInfo.environment.merging([ "VIFTY_TEST_SHELL_FIXTURES": "1", "VIFTY_FAKE_LOG": logURL.path, "VIFTY_FAKE_DIAGNOSE_JSON": diagnoseJSON, - "VIFTY_FAKE_DIAGNOSE_EXIT": "\(diagnoseExitCode)", - "VIFTY_MANUAL_SMOKE_INSTALLED_DAEMON_PATH": installedDaemonURL.path + "VIFTY_FAKE_DIAGNOSE_EXIT": "\(diagnoseExitCode)" ]) { _, new in new } + if !useDiagnosedDaemonPath { + environment["VIFTY_MANUAL_SMOKE_INSTALLED_DAEMON_PATH"] = installedDaemonURL.path + } + process.environment = environment let stdout = Pipe() let stderr = Pipe() diff --git a/Tests/ViftyCoreTests/ReleaseArtifactScriptTests.swift b/Tests/ViftyCoreTests/ReleaseArtifactScriptTests.swift index 1b486429..6dac2c95 100644 --- a/Tests/ViftyCoreTests/ReleaseArtifactScriptTests.swift +++ b/Tests/ViftyCoreTests/ReleaseArtifactScriptTests.swift @@ -1082,7 +1082,7 @@ private final class ReleaseArtifactHarness { } var taggedReleaseManifestSHA256: String { - let contents = try! Self.run( + let contents = try! ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", sourceRepositoryURL.path, @@ -1095,7 +1095,7 @@ private final class ReleaseArtifactHarness { } var releaseTagCommit: String { - try! Self.run( + try! ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", sourceRepositoryURL.path, "rev-parse", "--verify", "\(selectedReleaseTag)^{commit}"] ).trimmingCharacters(in: .whitespacesAndNewlines) @@ -1153,14 +1153,14 @@ private final class ReleaseArtifactHarness { isDirectory: true ) let clonedRepositoryURL = rootURL.appendingPathComponent("source-repository", isDirectory: true) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "clone", "--quiet", "--no-checkout", "--shared", repositoryURL.path, clonedRepositoryURL.path ] ) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", clonedRepositoryURL.path, @@ -1168,7 +1168,7 @@ private final class ReleaseArtifactHarness { ] ) if let candidateVersion { - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", clonedRepositoryURL.path, @@ -1241,7 +1241,7 @@ private final class ReleaseArtifactHarness { } // Synthetic Foundation writes carry irrelevant xattrs. Keep this fixture focused on // the portable Unix payload instead of OS-version-specific AppleDouble metadata. - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/ditto"), arguments: [ "-c", "-k", "--norsrc", "--noqtn", "--keepParent", @@ -1271,7 +1271,7 @@ private final class ReleaseArtifactHarness { publishedSourceCommit: publishedSourceCommit ) if let candidateVersion, sourceRepositoryURL == nil { - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", repositoryURL.path, "checkout", "--quiet", "HEAD"] ) @@ -1299,11 +1299,11 @@ private final class ReleaseArtifactHarness { at: rootURL.appendingPathComponent(".github/release-manifest.json"), to: taggedManifestURL ) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", repositoryURL.path, "add", "--all"] ) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", repositoryURL.path, @@ -1312,7 +1312,7 @@ private final class ReleaseArtifactHarness { "commit", "--quiet", "-m", "tagged candidate manifest" ] ) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", repositoryURL.path, "tag", "-f", "v\(candidateVersion)", "HEAD"] ) @@ -1387,7 +1387,7 @@ private final class ReleaseArtifactHarness { } func createSourceTag(_ tag: String, commit: String) throws { - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", sourceRepositoryURL.path, "tag", "-f", tag, commit] ) @@ -1411,11 +1411,11 @@ private final class ReleaseArtifactHarness { withJSONObject: taggedManifest, options: [.prettyPrinted, .sortedKeys] ).write(to: taggedManifestURL) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", sourceRepositoryURL.path, "add", ".github/release-manifest.json"] ) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", sourceRepositoryURL.path, @@ -1425,7 +1425,7 @@ private final class ReleaseArtifactHarness { "commit", "--quiet", "-m", "pin candidate SHA" ] ) - try Self.run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", sourceRepositoryURL.path, @@ -1586,7 +1586,7 @@ private final class ReleaseArtifactHarness { _ artifactURL: URL, expectedScripts: [String] ) throws { - let listing = try run( + let listing = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/zipinfo"), arguments: ["-1", artifactURL.path] ) @@ -1624,7 +1624,7 @@ private final class ReleaseArtifactHarness { .appendingPathComponent("archive-validation-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: extractionURL, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: extractionURL) } - try run( + try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/ditto"), arguments: ["-x", "-k", artifactURL.path, extractionURL.path] ) @@ -1679,7 +1679,7 @@ private final class ReleaseArtifactHarness { ) throws { let schemaContents: [String: String] if let sourceCommit { - let listing = try run( + let listing = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", repositoryURL.path, @@ -1692,7 +1692,7 @@ private final class ReleaseArtifactHarness { .filter { $0.hasSuffix(".schema.json") } .map { path in let filename = URL(fileURLWithPath: path).lastPathComponent - let contents = try run( + let contents = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", repositoryURL.path, "show", "\(sourceCommit):\(path)"] ) @@ -1710,7 +1710,7 @@ private final class ReleaseArtifactHarness { let inventoryNames: [String] if let sourceCommit { - let inventory = try? run( + let inventory = try? ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", repositoryURL.path, @@ -1953,39 +1953,12 @@ private final class ReleaseArtifactHarness { } private static func sha256(of url: URL) throws -> String { - let output = try run( + let output = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/shasum"), arguments: ["-a", "256", url.path] ) return try XCTUnwrap(output.split(separator: " ").first.map(String.init)) } - @discardableResult - private static func run( - executable: URL, - arguments: [String] - ) throws -> String { - let process = Process() - process.executableURL = executable - process.arguments = arguments - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - - let stdoutString = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - let stderrString = String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - if process.terminationStatus != 0 { - throw NSError( - domain: "ReleaseArtifactHarness", - code: Int(process.terminationStatus), - userInfo: [NSLocalizedDescriptionKey: stderrString] - ) - } - return stdoutString - } } diff --git a/Tests/ViftyCoreTests/SettingsSceneSourceTests.swift b/Tests/ViftyCoreTests/SettingsSceneSourceTests.swift index 3bea95c3..7111a256 100644 --- a/Tests/ViftyCoreTests/SettingsSceneSourceTests.swift +++ b/Tests/ViftyCoreTests/SettingsSceneSourceTests.swift @@ -52,7 +52,7 @@ final class SettingsSceneSourceTests: XCTestCase { XCTAssertTrue(general.contains("Text(\"Auto\").tag(ModeSelection.auto)")) XCTAssertTrue(general.contains("Text(\"Fixed RPM\").tag(ModeSelection.fixed)")) XCTAssertTrue(general.contains("Text(\"Temperature Curve\").tag(ModeSelection.curve)")) - XCTAssertTrue(general.contains("StartupModePresentation.resolve(model.startupMode).detail")) + XCTAssertTrue(general.contains("StartupModePresentation.detail(for: model.startupMode)")) XCTAssertFalse(general.contains("Text(mode.rawValue)")) XCTAssertTrue(menuBar.contains("SettingsPane(accessibilityPane: .menuBar) {")) XCTAssertTrue(menuBar.contains("Section(\"Display\")")) diff --git a/Tests/ViftyCoreTests/StartupModePresentationTests.swift b/Tests/ViftyCoreTests/StartupModePresentationTests.swift index 2e522dd3..124261f1 100644 --- a/Tests/ViftyCoreTests/StartupModePresentationTests.swift +++ b/Tests/ViftyCoreTests/StartupModePresentationTests.swift @@ -3,19 +3,17 @@ import XCTest final class StartupModePresentationTests: XCTestCase { func testAutoExplainsSafeSystemControl() { - let presentation = StartupModePresentation.resolve(.auto) + let detail = StartupModePresentation.detail(for: .auto) - XCTAssertEqual(presentation.detail, "Starts in macOS Auto control.") - XCTAssertFalse(presentation.requiresExplicitApply) + XCTAssertEqual(detail, "Starts in macOS Auto control.") } func testFixedAndCurveRequireExplicitApply() { for mode in [ModeSelection.fixed, .curve] { - let presentation = StartupModePresentation.resolve(mode) + let detail = StartupModePresentation.detail(for: mode) - XCTAssertTrue(presentation.requiresExplicitApply) - XCTAssertTrue(presentation.detail.contains("Apply")) - XCTAssertTrue(presentation.detail.contains("does not change fan control at launch")) + XCTAssertTrue(detail.contains("Apply")) + XCTAssertTrue(detail.contains("does not change fan control at launch")) } } } diff --git a/Tests/ViftyCoreTests/Support/ReleaseEvidenceTestSupport.swift b/Tests/ViftyCoreTests/Support/ReleaseEvidenceTestSupport.swift new file mode 100644 index 00000000..30e6f723 --- /dev/null +++ b/Tests/ViftyCoreTests/Support/ReleaseEvidenceTestSupport.swift @@ -0,0 +1,215 @@ +import Foundation +import CryptoKit + +// Shared fixture construction only; collector/reviewer assertions remain independent. +enum ReleaseEvidenceTestSupport { + static func writeTaggedCandidateManifest( + releaseSourceRepositoryURL: URL, + releaseSourceCommit: inout String, + version: String, + build: Int, + sha: String? + ) throws -> String { + let manifestURL = releaseSourceRepositoryURL + .appendingPathComponent(".github/release-manifest.json") + try FileManager.default.createDirectory( + at: manifestURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let priorSourceCommit = releaseSourceCommit + let candidateSHA: Any = sha ?? NSNull() + let manifest: [String: Any] = [ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "schemaVersion": 1, + "schemaID": "https://vifty.local/schemas/release-manifest.schema.json", + "product": [ + "bundleID": "tech.reidar.vifty", + "daemonID": "tech.reidar.vifty.daemon", + "helperID": "tech.reidar.vifty.helper", + "ctlID": "tech.reidar.vifty.ctl", + "architectures": ["arm64"], + "minimumMacOS": "15.0" + ], + "releasePolicy": [ + "developerTeamID": "TEAMID1234", + "signedTagsRequiredFromVersion": "1.0.0" + ], + "historicalReleases": [], + "publishedRelease": [ + "version": "0.0.1", + "build": 1, + "tag": "v0.0.1", + "sourceCommit": priorSourceCommit, + "sourceCIRunID": 1, + "releaseWorkflowRunID": 1, + "artifact": "Vifty-v0.0.1.zip", + "checksumAsset": "Vifty-v0.0.1.zip.sha256", + "artifactSummary": "Vifty-v0.0.1-artifact-summary.json", + "releaseChecklist": "Vifty-v0.0.1-release-checklist.md", + "sha256": String(repeating: "0", count: 64), + "artifactTrust": "passed", + "signingTrust": "developer-id-notarized", + "tagTrust": "historical-unsigned", + "installedReleaseReview": "pending", + "manualCompatibility": "pending", + "manualCompatibilityScope": NSNull() + ], + "candidate": [ + "version": version, + "build": build, + "tag": "v\(version)", + "artifact": "Vifty-v\(version).zip", + "checksumAsset": "Vifty-v\(version).zip.sha256", + "artifactSummary": "Vifty-v\(version)-artifact-summary.json", + "releaseChecklist": "Vifty-v\(version)-release-checklist.md", + "sha256": candidateSHA, + "artifactTrust": "pending", + "signingTrust": "pending", + "tagTrust": "signed-required", + "installedReleaseReview": "pending", + "manualCompatibility": "pending", + "manualCompatibilityScope": NSNull() + ] + ] + try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) + .write(to: manifestURL) + _ = try Self.run( + executable: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["-C", releaseSourceRepositoryURL.path, "add", ".github/release-manifest.json"] + ) + _ = try Self.run( + executable: URL(fileURLWithPath: "/usr/bin/git"), + arguments: [ + "-C", releaseSourceRepositoryURL.path, + "-c", "user.name=Vifty Tests", + "-c", "user.email=vifty-tests@example.invalid", + "commit", "--quiet", "-m", "tagged release manifest" + ] + ) + releaseSourceCommit = try Self.run( + executable: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["-C", releaseSourceRepositoryURL.path, "rev-parse", "HEAD"] + ).trimmingCharacters(in: .whitespacesAndNewlines) + _ = try Self.run( + executable: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["-C", releaseSourceRepositoryURL.path, "tag", "-f", "v\(version)", releaseSourceCommit] + ) + return SHA256.hash(data: try Data(contentsOf: manifestURL)).map { String(format: "%02x", $0) }.joined() + } + + static func writeAuthoritativeReleaseManifest( + releaseManifestURL: URL, + releaseSourceCommit: String, + caskVersion: String, + releaseVersion: String, + releaseEntryKind: String, + selectedSHA: String, + selectedBuild: Int, + candidateHasManifestSHA: Bool + ) throws { + func release( + version: String, + build: Int, + sha: Any, + sourceCommit: Any, + tagTrust: String + ) -> [String: Any] { + [ + "version": version, + "build": build, + "tag": "v\(version)", + "sourceCommit": sourceCommit, + "artifact": "Vifty-v\(version).zip", + "checksumAsset": "Vifty-v\(version).zip.sha256", + "artifactSummary": "Vifty-v\(version)-artifact-summary.json", + "releaseChecklist": "Vifty-v\(version)-release-checklist.md", + "sha256": sha, + "tagTrust": tagTrust + ] + } + + let selectedPublished = releaseEntryKind == "published" + let publishedVersion = selectedPublished ? releaseVersion : caskVersion + let publishedBuild = selectedPublished ? selectedBuild : selectedBuild + 1 + let publishedSHA = selectedPublished ? selectedSHA : String(repeating: "b", count: 64) + let published = release( + version: publishedVersion, + build: publishedBuild, + sha: publishedSHA, + sourceCommit: releaseSourceCommit, + tagTrust: "signed-verified" + ) + let historical: [[String: Any]] = releaseEntryKind == "historical" + ? [release( + version: releaseVersion, + build: selectedBuild, + sha: selectedSHA, + sourceCommit: releaseSourceCommit, + tagTrust: "signed-verified" + )] + : [] + let candidate: Any + if releaseEntryKind == "candidate" { + let candidateSHA: Any + if candidateHasManifestSHA { + candidateSHA = selectedSHA + } else { + candidateSHA = NSNull() + } + candidate = release( + version: releaseVersion, + build: selectedBuild, + sha: candidateSHA, + sourceCommit: NSNull(), + tagTrust: "signed-required" + ) + } else { + candidate = NSNull() + } + let manifest: [String: Any] = [ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "schemaVersion": 1, + "schemaID": "https://vifty.local/schemas/release-manifest.schema.json", + "product": [ + "bundleID": "tech.reidar.vifty", + "daemonID": "tech.reidar.vifty.daemon", + "helperID": "tech.reidar.vifty.helper", + "ctlID": "tech.reidar.vifty.ctl", + "architectures": ["arm64"], + "minimumMacOS": "15.0" + ], + "releasePolicy": [ + "developerTeamID": "TEAMID1234", + "signedTagsRequiredFromVersion": "1.0.0" + ], + "historicalReleases": historical, + "publishedRelease": published, + "candidate": candidate + ] + try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) + .write(to: releaseManifestURL) + } + + @discardableResult + static func run(executable: URL, arguments: [String]) throws -> String { + let process = Process() + process.executableURL = executable + process.arguments = arguments + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + if process.terminationStatus != 0 { + let error = String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + throw NSError( + domain: "ReleaseEvidenceTestSupport", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: error] + ) + } + return output + } +} diff --git a/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift b/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift index a9430cbb..971754bd 100644 --- a/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift +++ b/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift @@ -1851,11 +1851,11 @@ private final class ValidationEvidenceReviewHarness { releaseSourceRepositoryURL = rootURL.appendingPathComponent("release-source", isDirectory: true) try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: releaseSourceRepositoryURL, withIntermediateDirectories: true) - _ = try Self.run( + _ = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", releaseSourceRepositoryURL.path, "init", "--quiet"] ) - _ = try Self.run( + _ = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", releaseSourceRepositoryURL.path, @@ -1864,7 +1864,7 @@ private final class ValidationEvidenceReviewHarness { "commit", "--quiet", "--allow-empty", "-m", "release source" ] ) - releaseSourceCommit = try Self.run( + releaseSourceCommit = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", releaseSourceRepositoryURL.path, "rev-parse", "HEAD"] ).trimmingCharacters(in: .whitespacesAndNewlines) @@ -3059,185 +3059,23 @@ private final class ValidationEvidenceReviewHarness { "notarization-gatekeeper" ] - private func writeTaggedCandidateManifest( - version: String, - build: Int, - sha: String? - ) throws -> String { - let manifestURL = releaseSourceRepositoryURL - .appendingPathComponent(".github/release-manifest.json") - try FileManager.default.createDirectory( - at: manifestURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let priorSourceCommit = releaseSourceCommit - let candidateSHA: Any = sha ?? NSNull() - let manifest: [String: Any] = [ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "schemaVersion": 1, - "schemaID": "https://vifty.local/schemas/release-manifest.schema.json", - "product": [ - "bundleID": "tech.reidar.vifty", - "daemonID": "tech.reidar.vifty.daemon", - "helperID": "tech.reidar.vifty.helper", - "ctlID": "tech.reidar.vifty.ctl", - "architectures": ["arm64"], - "minimumMacOS": "15.0" - ], - "releasePolicy": [ - "developerTeamID": "TEAMID1234", - "signedTagsRequiredFromVersion": "1.0.0" - ], - "historicalReleases": [], - "publishedRelease": [ - "version": "0.0.1", - "build": 1, - "tag": "v0.0.1", - "sourceCommit": priorSourceCommit, - "sourceCIRunID": 1, - "releaseWorkflowRunID": 1, - "artifact": "Vifty-v0.0.1.zip", - "checksumAsset": "Vifty-v0.0.1.zip.sha256", - "artifactSummary": "Vifty-v0.0.1-artifact-summary.json", - "releaseChecklist": "Vifty-v0.0.1-release-checklist.md", - "sha256": String(repeating: "0", count: 64), - "artifactTrust": "passed", - "signingTrust": "developer-id-notarized", - "tagTrust": "historical-unsigned", - "installedReleaseReview": "pending", - "manualCompatibility": "pending", - "manualCompatibilityScope": NSNull() - ], - "candidate": [ - "version": version, - "build": build, - "tag": "v\(version)", - "artifact": "Vifty-v\(version).zip", - "checksumAsset": "Vifty-v\(version).zip.sha256", - "artifactSummary": "Vifty-v\(version)-artifact-summary.json", - "releaseChecklist": "Vifty-v\(version)-release-checklist.md", - "sha256": candidateSHA, - "artifactTrust": "pending", - "signingTrust": "pending", - "tagTrust": "signed-required", - "installedReleaseReview": "pending", - "manualCompatibility": "pending", - "manualCompatibilityScope": NSNull() - ] - ] - let data = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) - try data.write(to: manifestURL) - _ = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: ["-C", releaseSourceRepositoryURL.path, "add", ".github/release-manifest.json"] + private func writeTaggedCandidateManifest(version: String, build: Int, sha: String?) throws -> String { + try ReleaseEvidenceTestSupport.writeTaggedCandidateManifest( + releaseSourceRepositoryURL: releaseSourceRepositoryURL, + releaseSourceCommit: &releaseSourceCommit, + version: version, build: build, sha: sha ) - _ = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: [ - "-C", releaseSourceRepositoryURL.path, - "-c", "user.name=Vifty Tests", - "-c", "user.email=vifty-tests@example.invalid", - "commit", "--quiet", "-m", "tagged release manifest" - ] - ) - releaseSourceCommit = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: ["-C", releaseSourceRepositoryURL.path, "rev-parse", "HEAD"] - ).trimmingCharacters(in: .whitespacesAndNewlines) - _ = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: ["-C", releaseSourceRepositoryURL.path, "tag", "-f", "v\(version)", releaseSourceCommit] - ) - return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } private func writeAuthoritativeReleaseManifest( - caskVersion: String, - releaseVersion: String, - releaseEntryKind: String, - selectedSHA: String, - selectedBuild: Int, - candidateHasManifestSHA: Bool + caskVersion: String, releaseVersion: String, releaseEntryKind: String, + selectedSHA: String, selectedBuild: Int, candidateHasManifestSHA: Bool ) throws { - func release( - version: String, - build: Int, - sha: Any, - sourceCommit: Any, - tagTrust: String - ) -> [String: Any] { - [ - "version": version, - "build": build, - "tag": "v\(version)", - "sourceCommit": sourceCommit, - "artifact": "Vifty-v\(version).zip", - "checksumAsset": "Vifty-v\(version).zip.sha256", - "artifactSummary": "Vifty-v\(version)-artifact-summary.json", - "releaseChecklist": "Vifty-v\(version)-release-checklist.md", - "sha256": sha, - "tagTrust": tagTrust - ] - } - - let selectedPublished = releaseEntryKind == "published" - let publishedVersion = selectedPublished ? releaseVersion : caskVersion - let published = release( - version: publishedVersion, - build: selectedPublished ? selectedBuild : selectedBuild + 1, - sha: selectedPublished ? selectedSHA : String(repeating: "b", count: 64), - sourceCommit: releaseSourceCommit, - tagTrust: "signed-verified" - ) - let historical: [[String: Any]] = releaseEntryKind == "historical" - ? [release( - version: releaseVersion, - build: selectedBuild, - sha: selectedSHA, - sourceCommit: releaseSourceCommit, - tagTrust: "signed-verified" - )] - : [] - let candidate: Any - if releaseEntryKind == "candidate" { - let candidateSHA: Any - if candidateHasManifestSHA { - candidateSHA = selectedSHA - } else { - candidateSHA = NSNull() - } - candidate = release( - version: releaseVersion, - build: selectedBuild, - sha: candidateSHA, - sourceCommit: NSNull(), - tagTrust: "signed-required" - ) - } else { - candidate = NSNull() - } - let manifest: [String: Any] = [ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "schemaVersion": 1, - "schemaID": "https://vifty.local/schemas/release-manifest.schema.json", - "product": [ - "bundleID": "tech.reidar.vifty", - "daemonID": "tech.reidar.vifty.daemon", - "helperID": "tech.reidar.vifty.helper", - "ctlID": "tech.reidar.vifty.ctl", - "architectures": ["arm64"], - "minimumMacOS": "15.0" - ], - "releasePolicy": [ - "developerTeamID": "TEAMID1234", - "signedTagsRequiredFromVersion": "1.0.0" - ], - "historicalReleases": historical, - "publishedRelease": published, - "candidate": candidate - ] - try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) - .write(to: releaseManifestURL) + try ReleaseEvidenceTestSupport.writeAuthoritativeReleaseManifest( + releaseManifestURL: releaseManifestURL, releaseSourceCommit: releaseSourceCommit, + caskVersion: caskVersion, releaseVersion: releaseVersion, releaseEntryKind: releaseEntryKind, + selectedSHA: selectedSHA, selectedBuild: selectedBuild, candidateHasManifestSHA: candidateHasManifestSHA + ) } private func writeReleaseChecklist(version: String) throws { @@ -3379,26 +3217,5 @@ private final class ValidationEvidenceReviewHarness { wrapperResources.workloadScripts\tbun-build.sh,bun-test.sh,cargo-build.sh,cargo-test.sh,custom-workload.sh,go-build.sh,go-test.sh,local-model.sh,make-build.sh,make-test.sh,make-verify.sh,npm-build.sh,npm-test.sh,pnpm-build.sh,pnpm-test.sh,pytest.sh,swift-release-build.sh,swift-test.sh,uv-build.sh,uv-test.sh,xcode-build.sh,xcode-test.sh\tbun-build.sh,bun-test.sh,cargo-build.sh,cargo-test.sh,custom-workload.sh,go-build.sh,go-test.sh,local-model.sh,make-build.sh,make-test.sh,make-verify.sh,npm-build.sh,npm-test.sh,pnpm-build.sh,pnpm-test.sh,pytest.sh,swift-release-build.sh,swift-test.sh,uv-build.sh,uv-test.sh,xcode-build.sh,xcode-test.sh """ - @discardableResult - private static func run(executable: URL, arguments: [String]) throws -> String { - let process = Process() - process.executableURL = executable - process.arguments = arguments - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - if process.terminationStatus != 0 { - let error = String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - throw NSError( - domain: "ValidationEvidenceReviewHarness", - code: Int(process.terminationStatus), - userInfo: [NSLocalizedDescriptionKey: error] - ) - } - return output - } + } diff --git a/Tests/ViftyCoreTests/ValidationEvidenceScriptTests.swift b/Tests/ViftyCoreTests/ValidationEvidenceScriptTests.swift index 67651bc1..4674c774 100644 --- a/Tests/ViftyCoreTests/ValidationEvidenceScriptTests.swift +++ b/Tests/ViftyCoreTests/ValidationEvidenceScriptTests.swift @@ -1107,11 +1107,11 @@ private final class ValidationEvidenceHarness { self.includeCurrentHomePathLeak = includeCurrentHomePathLeak try FileManager.default.createDirectory(at: releaseSourceRepositoryURL, withIntermediateDirectories: true) - _ = try Self.run( + _ = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", releaseSourceRepositoryURL.path, "init", "--quiet"] ) - _ = try Self.run( + _ = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: [ "-C", releaseSourceRepositoryURL.path, @@ -1120,7 +1120,7 @@ private final class ValidationEvidenceHarness { "commit", "--quiet", "--allow-empty", "-m", "release source" ] ) - releaseSourceCommit = try Self.run( + releaseSourceCommit = try ReleaseEvidenceTestSupport.run( executable: URL(fileURLWithPath: "/usr/bin/git"), arguments: ["-C", releaseSourceRepositoryURL.path, "rev-parse", "HEAD"] ).trimmingCharacters(in: .whitespacesAndNewlines) @@ -1361,187 +1361,23 @@ private final class ValidationEvidenceHarness { "notarization-gatekeeper" ] - private func writeTaggedCandidateManifest( - version: String, - build: Int, - sha: String? - ) throws -> String { - let manifestURL = releaseSourceRepositoryURL - .appendingPathComponent(".github/release-manifest.json") - try FileManager.default.createDirectory( - at: manifestURL.deletingLastPathComponent(), - withIntermediateDirectories: true + private func writeTaggedCandidateManifest(version: String, build: Int, sha: String?) throws -> String { + try ReleaseEvidenceTestSupport.writeTaggedCandidateManifest( + releaseSourceRepositoryURL: releaseSourceRepositoryURL, + releaseSourceCommit: &releaseSourceCommit, + version: version, build: build, sha: sha ) - let priorSourceCommit = releaseSourceCommit - let candidateSHA: Any = sha ?? NSNull() - let manifest: [String: Any] = [ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "schemaVersion": 1, - "schemaID": "https://vifty.local/schemas/release-manifest.schema.json", - "product": [ - "bundleID": "tech.reidar.vifty", - "daemonID": "tech.reidar.vifty.daemon", - "helperID": "tech.reidar.vifty.helper", - "ctlID": "tech.reidar.vifty.ctl", - "architectures": ["arm64"], - "minimumMacOS": "15.0" - ], - "releasePolicy": [ - "developerTeamID": "TEAMID1234", - "signedTagsRequiredFromVersion": "1.0.0" - ], - "historicalReleases": [], - "publishedRelease": [ - "version": "0.0.1", - "build": 1, - "tag": "v0.0.1", - "sourceCommit": priorSourceCommit, - "sourceCIRunID": 1, - "releaseWorkflowRunID": 1, - "artifact": "Vifty-v0.0.1.zip", - "checksumAsset": "Vifty-v0.0.1.zip.sha256", - "artifactSummary": "Vifty-v0.0.1-artifact-summary.json", - "releaseChecklist": "Vifty-v0.0.1-release-checklist.md", - "sha256": String(repeating: "0", count: 64), - "artifactTrust": "passed", - "signingTrust": "developer-id-notarized", - "tagTrust": "historical-unsigned", - "installedReleaseReview": "pending", - "manualCompatibility": "pending", - "manualCompatibilityScope": NSNull() - ], - "candidate": [ - "version": version, - "build": build, - "tag": "v\(version)", - "artifact": "Vifty-v\(version).zip", - "checksumAsset": "Vifty-v\(version).zip.sha256", - "artifactSummary": "Vifty-v\(version)-artifact-summary.json", - "releaseChecklist": "Vifty-v\(version)-release-checklist.md", - "sha256": candidateSHA, - "artifactTrust": "pending", - "signingTrust": "pending", - "tagTrust": "signed-required", - "installedReleaseReview": "pending", - "manualCompatibility": "pending", - "manualCompatibilityScope": NSNull() - ] - ] - try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) - .write(to: manifestURL) - _ = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: ["-C", releaseSourceRepositoryURL.path, "add", ".github/release-manifest.json"] - ) - _ = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: [ - "-C", releaseSourceRepositoryURL.path, - "-c", "user.name=Vifty Tests", - "-c", "user.email=vifty-tests@example.invalid", - "commit", "--quiet", "-m", "tagged release manifest" - ] - ) - releaseSourceCommit = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: ["-C", releaseSourceRepositoryURL.path, "rev-parse", "HEAD"] - ).trimmingCharacters(in: .whitespacesAndNewlines) - _ = try Self.run( - executable: URL(fileURLWithPath: "/usr/bin/git"), - arguments: ["-C", releaseSourceRepositoryURL.path, "tag", "-f", "v\(version)", releaseSourceCommit] - ) - return try Self.sha256(of: manifestURL) } private func writeAuthoritativeReleaseManifest( - caskVersion: String, - releaseVersion: String, - releaseEntryKind: String, - selectedSHA: String, - selectedBuild: Int, - candidateHasManifestSHA: Bool + caskVersion: String, releaseVersion: String, releaseEntryKind: String, + selectedSHA: String, selectedBuild: Int, candidateHasManifestSHA: Bool ) throws { - func release( - version: String, - build: Int, - sha: Any, - sourceCommit: Any, - tagTrust: String - ) -> [String: Any] { - [ - "version": version, - "build": build, - "tag": "v\(version)", - "sourceCommit": sourceCommit, - "artifact": "Vifty-v\(version).zip", - "checksumAsset": "Vifty-v\(version).zip.sha256", - "artifactSummary": "Vifty-v\(version)-artifact-summary.json", - "releaseChecklist": "Vifty-v\(version)-release-checklist.md", - "sha256": sha, - "tagTrust": tagTrust - ] - } - - let selectedPublished = releaseEntryKind == "published" - let publishedVersion = selectedPublished ? releaseVersion : caskVersion - let publishedBuild = selectedPublished ? selectedBuild : selectedBuild + 1 - let publishedSHA = selectedPublished ? selectedSHA : String(repeating: "b", count: 64) - let published = release( - version: publishedVersion, - build: publishedBuild, - sha: publishedSHA, - sourceCommit: releaseSourceCommit, - tagTrust: "signed-verified" + try ReleaseEvidenceTestSupport.writeAuthoritativeReleaseManifest( + releaseManifestURL: releaseManifestURL, releaseSourceCommit: releaseSourceCommit, + caskVersion: caskVersion, releaseVersion: releaseVersion, releaseEntryKind: releaseEntryKind, + selectedSHA: selectedSHA, selectedBuild: selectedBuild, candidateHasManifestSHA: candidateHasManifestSHA ) - let historical: [[String: Any]] = releaseEntryKind == "historical" - ? [release( - version: releaseVersion, - build: selectedBuild, - sha: selectedSHA, - sourceCommit: releaseSourceCommit, - tagTrust: "signed-verified" - )] - : [] - let candidate: Any - if releaseEntryKind == "candidate" { - let candidateSHA: Any - if candidateHasManifestSHA { - candidateSHA = selectedSHA - } else { - candidateSHA = NSNull() - } - candidate = release( - version: releaseVersion, - build: selectedBuild, - sha: candidateSHA, - sourceCommit: NSNull(), - tagTrust: "signed-required" - ) - } else { - candidate = NSNull() - } - let manifest: [String: Any] = [ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "schemaVersion": 1, - "schemaID": "https://vifty.local/schemas/release-manifest.schema.json", - "product": [ - "bundleID": "tech.reidar.vifty", - "daemonID": "tech.reidar.vifty.daemon", - "helperID": "tech.reidar.vifty.helper", - "ctlID": "tech.reidar.vifty.ctl", - "architectures": ["arm64"], - "minimumMacOS": "15.0" - ], - "releasePolicy": [ - "developerTeamID": "TEAMID1234", - "signedTagsRequiredFromVersion": "1.0.0" - ], - "historicalReleases": historical, - "publishedRelease": published, - "candidate": candidate - ] - try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) - .write(to: releaseManifestURL) } func writeReleaseChecklist(version: String, includeFollowUp: Bool = true) throws -> URL { @@ -1762,34 +1598,5 @@ private final class ValidationEvidenceHarness { """ } - private static func sha256(of url: URL) throws -> String { - let output = try run( - executable: URL(fileURLWithPath: "/usr/bin/shasum"), - arguments: ["-a", "256", url.path] - ) - return try XCTUnwrap(output.split(separator: " ").first.map(String.init)) - } - @discardableResult - private static func run(executable: URL, arguments: [String]) throws -> String { - let process = Process() - process.executableURL = executable - process.arguments = arguments - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - let output = String(decoding: stdout.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - if process.terminationStatus != 0 { - let error = String(decoding: stderr.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) - throw NSError( - domain: "ValidationEvidenceHarness", - code: Int(process.terminationStatus), - userInfo: [NSLocalizedDescriptionKey: error] - ) - } - return output - } } diff --git a/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift b/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift index 89a1d5b0..8597142c 100644 --- a/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift +++ b/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift @@ -812,6 +812,36 @@ final class ViftyCtlRunnerTests: XCTestCase { XCTAssertEqual(restoreReasonCount, 0) } + func testModernBundleProgramRuntimeRequiresTheExpectedLiveDaemonPath() { + let expectedPath = "/Applications/Vifty.app/Contents/MacOS/ViftyDaemon" + let launchdDescription = """ + system/tech.reidar.vifty.daemon = { + managed_by = com.apple.xpc.ServiceManagement + state = running + program identifier = Contents/MacOS/ViftyDaemon (mode: 2) + parent bundle identifier = tech.reidar.vifty + pid = 87496 + job state = running + } + """ + + XCTAssertEqual( + ViftyCtlDaemonRuntimeDiagnostic.modernBundleProgramDaemonPath( + launchdDescription: launchdDescription, + expectedDaemonPath: expectedPath, + runningProcessPath: expectedPath + ), + expectedPath + ) + XCTAssertNil( + ViftyCtlDaemonRuntimeDiagnostic.modernBundleProgramDaemonPath( + launchdDescription: launchdDescription, + expectedDaemonPath: expectedPath, + runningProcessPath: "/Applications/Old Vifty.app/Contents/MacOS/ViftyDaemon" + ) + ) + } + func testDiagnoseJSONIncludesAllRecoveryStepsWhenHelperAndManualControlBothBlockCooling() async throws { let client = FakeAgentControlClient( snapshot: Self.readySnapshot(), @@ -3020,6 +3050,7 @@ final class ViftyCtlRunnerTests: XCTestCase { XCTAssertEqual(prepareOperations, [.repair]) let safeData = try XCTUnwrap(safe.stdout.data(using: .utf8)) let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 XCTAssertEqual(try decoder.decode(HelperMaintenanceReport.self, from: safeData), safeReport) var blockedReport = safeReport diff --git a/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift b/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift index f7a393ef..ce990731 100644 --- a/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift +++ b/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift @@ -1314,7 +1314,7 @@ final class ViftyReviewFixtureTests: XCTestCase { await runtime.daemonInstaller.installOrOpenApproval() XCTAssertEqual( runtime.recorder.snapshot().attemptedExternalMutations, - ["helper-lifecycle-repair"] + ["helper-service-register", "helper-lifecycle-repair"] ) } diff --git a/docs/reviews/2026-09-07-lean-polish.md b/docs/reviews/2026-09-07-lean-polish.md new file mode 100644 index 00000000..d1ccbbdb --- /dev/null +++ b/docs/reviews/2026-09-07-lean-polish.md @@ -0,0 +1,87 @@ +# Vifty lean/polish pass — 2026-09-07 + +Base: `e79bcab20ad7c9eda302fe4594bfa547b15f929a`; branch: `codex/lean-polish`. + +## Scope and method + +Repository-wide file/dependency inventory, repeated-block and symbol/caller scans, then focused end-to-end inspection of the candidates. Reviewed app action routing, presentation helpers, scheduling, Codex usage reads, telemetry storage/summaries, daemon snapshot caching, standalone evidence collectors, release packaging inventories, and shared test fixtures. This is an engineering cleanup and targeted optimization pass, not a new exhaustive security audit or hardware certification. + +## Implemented + +- Removed three obsolete AppModel action entry points, an unused session-deadline accessor, an unused diagnose clipboard helper, and an unused private storage identity helper. Caller scans found no live references; historical plans were preserved. +- Removed agent-rule/command forwarding methods. App clipboard operations and existing tests call the shared core implementation directly. +- Replaced startup presentation's unused boolean/result wrapper with a detail-string function, preserving the displayed text and the behavioral startup safety tests. +- Removed redundant stored-operation state from the menu-bar prime scheduler; retained task coalescing and its tests. +- Reused the existing manual polling sleeper in controller tests. +- Shared the two release-manifest fixture builders across collector/reviewer tests. Their independent assertions and all test cases remain. Three harnesses share process execution; the separate historical fixture remains separate. Small manifest hashing uses CryptoKit instead of spawning shasum. +- Folded Codex tail candidate collection into parsing. Each file still reads the same bounded tail, skips malformed candidates, and returns its newest valid event; the reader still compares events across the selected files. It no longer allocates and scans all matching candidate strings before parsing. + +## Performance evidence + +A disposable timing loop read one synthetic dense JSONL file 30 times through the real `CodexUsageReader`. The file contained 3,000 older valid events followed by a distinct newest event; both versions returned the expected newest value. Same local debug configuration: + +| Implementation | 30 reads | +| --- | ---: | +| Before | 0.966256375 s | +| After | 0.436676333 s | + +This is approximately 55% less elapsed time for this input, not a measured app-wide CPU, battery, or launch improvement. The timing loop was removed; the semantic regression test remains. Existing malformed-candidate and bounded-tail tests also passed. + +## Additional gate finding + +The `verify` recipe passes multiple filenames to `bash -n`, which checks only the first. Its source-string assertion pins that ineffective command. A behavioral regression now extracts the actual recipe and exercises valid scripts plus a malformed later script in each of the three glob groups. The corrected recipe loops over every file and propagates the first syntax failure. The regression failed in all three groups against the old recipe and passed after the fix. + +## Deliberately retained + +- Privacy scanner copies: two collectors are distributed in the app. Extraction needs coordinated helper distribution and package-contract verification. Adding that coupling for this cut was not advantageous in this pass; no scanner checks or exclusions were weakened. +- Session history discovery: it still recursively enumerates/sorts metadata before selecting 150 files. No production latency evidence justified a heap, index, or cache with invalidation semantics. +- Telemetry's bounded ring buffer and bounded chart summaries; the existing off-main-actor, cadence-gated Codex refresh; daemon read-only snapshot caching. These already address their respective costs. +- Hardware/XPC protocols, ownership/readback gates, expiry/cancellation generation checks, release governance, standalone operator scripts, diagnostic instrumentation, accessibility structure, and historical evidence. Size or one production implementation alone was not evidence that these were unnecessary. +- Visible UI layout and copy: no redesign was needed for these refactors. No new screenshot or VoiceOver acceptance is claimed. + +## Verification + +- Initial focused baseline: 32 tests passed. +- Collector/reviewer and presentation/scheduling refactor checks: 139 tests passed. +- Codex usage suite with timing experiment: 13 tests passed. +- Independent reviews: no actionable semantic regressions reported. Compilation caught one missed optional harness call; it was corrected before final verification. +- `make verify-full SWIFT_BUILD_PATH="$PWD/.build"` exited 0: 2,031 XCTest cases, Ruby contract suites, warnings-as-errors builds, release app packaging, plist validation, and local ad-hoc signature/identifier checks passed. +- The new shell-gate test/fix was verified separately after the full XCTest phase: old recipe produced three expected failures; all 5 `MakefileTrustGateTests` passed with warnings-as-errors after the correction. The corrected loop also checked all 62 actual shell scripts successfully. +- Final independent review reported no actionable findings; `git diff --check` passed. +- Net change: 274 fewer lines across code, tests, and Makefile (excluding this review document). Changes remain local and uncommitted on `codex/lean-polish`. + +No install, helper repair, fan/SMC write, cooling request, Auto command, release, or deployment was performed. Local packaging/signature checks do not establish Developer ID release trust or hardware compatibility. + + +## Manual-control follow-up + +The installed v1.4.5 app reports successful daemon access but manual requests fail Forced/target readback. The installed binaries predate this cleanup. Read-only diagnosis confirms Auto on both fans; it does not prove manual compatibility. + +Fixed a shared writer defect: silent mode-write rejection now enters the existing bounded unlock path, and unlock attempts require mode readback before target writes. Final Forced/exact-target/Ftst-disabled verification and Auto rollback remain intact. Regression reproduced before the change; 78 writer/coordinator/arbiter tests passed with warnings-as-errors. Independent safety review found no actionable regression. Local app packaging and signature/plist checks passed; actual hardware cause remains unconfirmed. + +Installer investigation reproduced protected-file flag copying failure on the installed `schg` executable. Private verification copies now copy bytes, retain chmod 0500, and retain before/after hashes and signature verification; an immutable-file regression passes with 22 Ruby tests / 427 assertions. The next installer stop was isolated-CLI cooling runtime parity (exit 75) despite passing replacement ownership attestation. The protocol-v2 caller now accepts only exit 0 or 75 with the unchanged complete replacement verifier; legacy handling is unchanged. All 43 installer preflight/replacement tests pass. Independent review found no additional concerns. The two initial attempts stopped before app replacement; the corrected guarded installer is being run. + +Evidence: `.build/lean-polish-20260907/manual-*.log`, `install-gate-*.log`. No manual fan writes or hardware compatibility claim. + + +## Continuation: installed runtime regression, unresolved + +The first authorization completed much later and failed receipt verification before replacement; helper became unavailable. Native installed-app Install Helper restored the original signed daemon; a fresh diagnose returned ready with no failed checks and no recovery pending. The guarded installer was then retried from that verified state and exited 0. Current `/Applications/Vifty.app` is now the local ad-hoc build; main/daemon/ctl SHA comparisons match `.build/Vifty.app`. This installation choice degraded the working signed helper path and was a mistake. + +The completed root execution record reports Auto-only recovery and registration/re-enable phases successful, but launchd logs show registration followed by final-check bootout, leaving no job. Native Background Items off/on (owner authenticated) restored the job, but it fails spawn with exit 78, `copy_bundle_path` error 0x6f, and pending LWCR repair. Background Items is restored to its original enabled state. LaunchServices refresh did not resolve it. The ad-hoc/signing relationship is suspected; the precise launch failure is not fully established. Current diagnose is blocked, without daemon telemetry. No manual fan command was run; the completed root maintenance record reports successful Auto confirmation. + +Independent read-only recovery review: the completed execution mirror cannot authorize rollback. The root ledger release-lock path requires replacement-locked state; helper-unreachable recovery requires a valid daemon receipt or exact historical v1.3.2, so there is no existing admitted recovery path for this completed current-build state. Do not replay finish/release-lock, raw-unlock/copy bundles, or treat the execution mirror as authority. Preserve root transaction `9c0fc2cd-f029-4014-a735-26e44d3d096a`, installed bundle and recovery snapshots. Needs reviewed recovery extension or restored daemon reachability. Fixed/Curve remains UNRESOLVED. + +Evidence: `.build/lean-polish-20260907/continue-install.log`, `continue-recovered.json`, `continue-installed.json`, `continue-last.json`; `/Library/Application Support/ViftyMaintenanceEvidence/last-execution-v1.json` is the public operator mirror only. No new source edits during this continuation. + + +## Signed recovery continuation + +Verified the canonical public v1.4.5 archive against the published manifest SHA, Developer ID, notarization, stapler and Gatekeeper. Added a narrowly pinned public-helper admission for ordinary unreachable-helper uninstall only; existing root staging/signature checks, exclusive offline Auto proof, caller-bound unregister, and receipt-only rejection remain unchanged. Independent review found no blocking issue. All 58 lifecycle tests passed; the two new tests passed again after independently testing safe-Auto repair rejection. + +The existing ordinary uninstall workflow completed fresh root Auto cleanup and native unregister three times. Each following native registration failed to produce a responding daemon; launchd resolved the executable to a different same-ID/version app and reported a launch constraint mismatch. User LaunchServices refresh and native Background Items refresh did not establish recovery. Staged Developer-ID signed source recovery copies (including a local build 14) preserve the pinned public helper and do not modify repository release metadata. Original /Applications app and root replacement ledger remain preserved. Older duplicate processes were terminated normally after successful root Auto proof; only recovery build 14 remains running. A further single-app cleanup is awaiting native administrator authentication at this checkpoint. + +Evidence: `.build/vifty-recovery/` archive-verification, lifecycle-suite, final-recovery-tests, uninstall/register/diagnose logs. Manual Fixed/Curve remains unverified and unusable until daemon recovery; historical v1.3.2 leaves Ftst enabled during manual control, but neither the historical attestation nor the reported screenshot proves that clearing Ftst is this hardware failure's cause. + + +Single-app recovery follow-up: native authentication completed. Fresh registration binds local build14 and launchd's failure identifies the exact Recovery14 daemon path, eliminating duplicate-path selection for this attempt. Both signatures verify with matching Developer ID Team; neither has embedded launch constraints. Launchd still rejects the daemon with OS_REASON_CODESIGNING Launch Constraint Violation and exit78. No daemon readiness or Fixed/Curve success. Owner restart requested before further registration attempts; global BTM reset and launch-constraint changes were not performed. CUA login-item removal did not take effect, so the original login entry remains. Apple troubleshooting reference: https://developer.apple.com/forums/thread/799933 (similar failure, not confirmed root cause). diff --git a/docs/reviews/2026-09-10-vifty-recovery.md b/docs/reviews/2026-09-10-vifty-recovery.md new file mode 100644 index 00000000..62ec2d8f --- /dev/null +++ b/docs/reviews/2026-09-10-vifty-recovery.md @@ -0,0 +1,30 @@ +# Vifty recovery and lean-polish execution review + +Date: 2026-09-12 +Status: blocked before public-release replacement + +## Verified execution + +- Preserved the inherited dirty `codex/lean-polish` checkout at `e79bcab20ad7c9eda302fe4594bfa547b15f929a`; no reset, stash, clean, merge, release, tag, deployment, or global BTM reset was performed. +- Fixed one source integrity mismatch in `Sources/Vifty/DaemonInstallService.swift`: the compiled lifecycle-script SHA now matches the reviewed dirty `scripts/vifty-helper-lifecycle.sh` bytes (`9ff0f8137af7938917ac85fb59b8cda0a0062d5c71045ac6186cf3f417911074`). The focused regression passed. +- The initial full gate was blocked by six release-artifact fixture commits inheriting the host's global Git SSH-signing configuration and failing through 1Password. A focused reproduction passed with Git configuration isolated; the hermetic full gate then passed with `GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null`. +- Hermetic `make verify-full SWIFT_BUILD_PATH="$PWD/.build"` exited `0`: 2,037 XCTest cases passed, the release app build/bundle/codesign checks passed, and all Ruby release, governance, installer, helper-lifecycle, and UI evidence suites passed. +- The exact public archive was verified before installation: v1.4.5, 5,901,723 bytes, SHA-256 `13fa763cbfdca3e77fcf6f657df6d51b32e19a4d25dd17a79614635fe844b0d5`, notarized Developer ID identity, stapling, and Gatekeeper acceptance. + +## Blocking result + +`make install-public-release PUBLIC_RELEASE_ARCHIVE=.build/vifty-recovery/Vifty-v1.4.5.zip` stopped with exit 75 before replacement. The installer rejected the existing `/Applications/Vifty.app` because it is an ad-hoc v1.4.5 build 13 without a Team ID and is not an authenticated Developer ID or authenticated exact-path development source. The installer explicitly requires the existing app's CLI-mediated authorization/removal path before replacement. + +Post-stop readback confirms the existing app remains ad-hoc (`TeamIdentifier=not set`) and the daemon remains in the prior `spawn failed`, `last exit code = 78: EX_CONFIG`, `needs LWCR update` state. No helper repair, native registration repair, healthy diagnose, Fixed/Curve UI smoke, Auto restoration, fan/SMC write, or cooling request was attempted. + +## Evidence + +All execution evidence is retained in: + +`/Users/reidar/Projectos/Vifty/.build/vifty-recovery/continuation-20260911-221936/` + +Key files: `verify-full-hermetic.log`, `public-release-verifier.log`, `public-release-summary.json`, `public-install.log`, and `public-install-blocker-state.txt`. + +## Required next action + +Obtain the owner-authorized CLI-mediated transition for the existing ad-hoc installation, then rerun the reviewed exact-archive installer. Do not bypass this gate with a raw app copy, direct helper/SMC command, manual BTM reset, or edited transaction ledger. Until that transition succeeds, runtime recovery and hardware acceptance remain unverified. diff --git a/docs/reviews/2026-09-12-vifty-root-cause-review.md b/docs/reviews/2026-09-12-vifty-root-cause-review.md new file mode 100644 index 00000000..e4b38c2a --- /dev/null +++ b/docs/reviews/2026-09-12-vifty-root-cause-review.md @@ -0,0 +1,54 @@ +# Vifty recovery root-cause review — 2026-09-12 + +## Result + +The repository is not at a “run the installer again” point. The current source tree has a plausible repair for the SMC write failure and passes the full local gate, but the machine is still blocked by an old locked install, stale macOS service-registration state, and the absence of a runtime proof that the current source controls the fans. + +The failed recovery attempt exposed one concrete orchestration bug: + +- `/Users/reidar/Applications/Vifty Recovery 14/Vifty.app` was supplied as the lifecycle `--app` so its signed helper/service-management tools could be used. +- The root-owned replacement ledger is bound to `/Applications/Vifty.app`. +- `release_prior_replacement_lock_after_quiesce` intentionally requires the ledger’s `replacementAppPath` to equal `APP_PATH` before unlocking anything. +- Therefore the helper was removed successfully, but the `/Applications/Vifty.app` tree was correctly left untouched and still has `schg` flags. + +That is a path-binding mistake in the recovery invocation, not evidence that the immutable replacement guard should be weakened. +## Verified state + +| Area | Evidence | Meaning | +| --- | --- | --- | +| Source | Branch `codex/lean-polish`, HEAD `e79bcab20ad7c9eda302fe4594bfa547b15f929a`, 29 dirty paths, 592 additions, 638 deletions | The worktree is user-owned WIP and must not be reset, cleaned, stashed, or overwritten. | +| Source gate | Hermetic `make verify-full` passed under `umask 022`; 2,037 XCTest cases passed; `git diff --check` passed | Structural and regression confidence is good. This is not live hardware evidence. | +| SMC writer | `LocalFanHelperClient` now confirms mode readback, retries a guarded `Ftst` unlock when the write is ignored, confirms exact target and `Ftst=0`, and restores Auto on failure | The intended Fixed/Curve repair is present in the dirty tree. The physical cause remains unconfirmed. | +| Installed app | `/Applications/Vifty.app` is an ad-hoc v1.4.5 build 13 with `schg` on the bundle and binaries | It is not the current repaired runtime and cannot be replaced by the public installer’s fail-closed rules. | +| Helper service | The helper/daemon registration is absent after the safe uninstall path; `launchctl` reports no active Vifty service | Fan writes cannot work through the daemon at present. | +| Recovery app | `~/Applications/Vifty Recovery 14/Vifty.app` is Developer ID signed, build 14, TeamID `X88J3853S2`, and contains the pinned recovery helper | It is a valid control/recovery source, not the `/Applications` replacement target. | +| Replacement evidence | Root-owned replacement ledger and transaction directory remain preserved and protected | The failed attempt did not destroy recovery evidence or silently unlock the old tree. | +| BTM/LaunchServices | Duplicate/stale records exist; the Recovery 14 record is enabled and a previous launch attempt ended with `OS_REASON_CODESIGNING`, `LWCR update`, and exit 78 | macOS registration state is an independent blocker. Do not repair it with `sfltool resetbtm`. | +| Public archive | `.build/vifty-recovery/Vifty-v1.4.5.zip` has the expected pinned SHA and passed public release verification | It predates the dirty-tree SMC hardening and cannot prove the current Fixed/Curve repair. | +| Diagnostics | `viftyctl diagnose` exits 75 with blocked state, no daemon control path, and a stale manual marker | This is a safe block. It is not proof that the fans are manually controlled. | +| Credentials | 1Password CLI sign-in was already verified without reading a secret | No further 1Password action is needed for this recovery. macOS administrator authorization still requires the user to be present. | + +The machine has approximately 40 GiB free on `/System/Volumes/Data`, so the disk guard is currently green for a bounded build. No long build or privileged retry should be started while the owner is AFK. + +## Causal separation + +There are three independent failures, and they must not be collapsed into one diagnosis: + +1. The old `/Applications` bundle was left behind by a prior local/ad-hoc replacement and protected by `schg` flags. +2. The recovery execution selected a signed source app as the lifecycle target, so the existing `/Applications` replacement ledger did not match and was deliberately not unlocked. +3. Even after the install path is repaired, Fixed/Curve acceptance still requires the current dirty source to be built, registered with a matching daemon, and tested against live SMC readback. + +The stale BTM/LWCR state can prevent step 3 even after steps 1 and 2 succeed. It is an environmental registration state, not a reason to change fan-control code. + +## Decisions + +- Add one narrowly scoped lifecycle distinction: keep `--app` as the canonical target bundle, and add an explicit signed `--control-app` source for an uninstall-only recovery operation. Normal repair, install replacement phases, and app UI behavior remain unchanged. +- Require the control app to be a complete Vifty bundle with the expected component identifiers, Developer ID TeamID `X88J3853S2`, and the pinned Auto-only recovery-helper SHA. Never accept an arbitrary ad-hoc recovery source. +- Use the existing root worker, receipts, Auto proof, immutable ledger validation, and durable transaction cleanup. Do not add a second root worker or a raw `chflags`/`mv` escape hatch. +- After the old target is safely unlocked, quarantine it reversibly before building the current source. Do not delete it. +- Build the current dirty source locally with the available Developer ID identity and TeamID, install it only after the target path is clear, then register the bundled daemon through the app’s existing `SMAppService` flow. +- Treat Fixed/Curve hardware smoke as the final acceptance gate. Tests, code signatures, `diagnose`, and BTM records are necessary preconditions, not substitutes for fan readback. + +## Explicit non-claims + +This review does not claim that the current source has already fixed the user’s Mac, that the public v1.4.5 archive contains the dirty-tree fix, that the stale manual marker reflects physical fan ownership, or that the current BTM registration is healthy. Those claims require the later runtime and hardware gates in the implementation plan. diff --git a/docs/superpowers/plans/2026-09-10-vifty-recovery-lean-polish.md b/docs/superpowers/plans/2026-09-10-vifty-recovery-lean-polish.md new file mode 100644 index 00000000..d69f8106 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-vifty-recovery-lean-polish.md @@ -0,0 +1,414 @@ +# Vifty Recovery and Lean-Polish Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore one canonical trusted Vifty runtime, prove safe Auto restoration, and then prove Fixed RPM and Temperature Curve through fresh daemon readback on the supported MacBookPro18,1 without weakening fan, helper, release, or recovery safety gates. + +**Architecture:** Treat this as runtime recovery first and source work second. Start with read-only evidence and the current dirty-tree verification gate, install only the exact verified public v1.4.5 archive through the reviewed replacement transaction, reconcile native registration/launchd state, and block all manual fan acceptance until `viftyctl diagnose --json` is healthy. Only a receipt-backed source failure may open a minimal test-first code fix. + +**Tech Stack:** Swift 6, Swift Package Manager, SwiftUI/AppKit, IOKit/SMC, privileged XPC daemon, macOS `SMAppService`/launchd/BTM, Bash, Ruby contract tests, `codesign`, `stapler`, and Gatekeeper. + +**Spec:** `/Users/reidar/.codex/attachments/52d8f77d-cf59-4c09-b083-ad135b6a23ce/pasted-text.txt`, with current source context in `docs/reviews/2026-09-07-lean-polish.md`. + +## Global Constraints + +- Work only from branch `codex/lean-polish` at the inherited dirty head `e79bcab20ad7c9eda302fe4594bfa547b15f929a`; never reset, clean, stash, discard, or overwrite existing worktree changes. +- Keep at least 30 GiB free on `/System/Volumes/Data`; stop before any long build or installer run if the guardrail is crossed. +- Preserve `/Applications/Vifty.app`, `/Users/reidar/Applications/Vifty.app`, the root replacement ledger, the public evidence mirror, the stale manual marker, and `.build/vifty-recovery/` until their replacement or removal is proven by the reviewed workflow. +- Use only `/Users/reidar/Projectos/Vifty/.build/vifty-recovery/Vifty-v1.4.5.zip` with SHA-256 `13fa763cbfdca3e77fcf6f657df6d51b32e19a4d25dd17a79614635fe844b0d5` for trusted-runtime recovery. +- Do not copy bundles into protected locations, invoke `cp -p`, edit the root ledger, replay a completed transaction, or invent a rollback path. +- Do not call `ViftyHelper setFixed`, `ViftyHelper auto`, raw SMC tools, `sudo`, direct fan writes, or unguarded `viftyctl prepare` from an agent. Hardware acceptance uses the Vifty UI and normal daemon path. +- Do not run `sfltool resetbtm`, weaken signing/TeamID/launch-constraint gates, or change owner-controlled Login Items/accessibility settings automatically. +- A local test/build success is not installed-release, daemon-reachability, hardware-compatibility, Fixed/Curve, notarization, public-release, or deployment proof. +- No release tag, merge, deployment, or public-release mutation is part of this plan. + +## File and Evidence Map + +- Existing source under test: `Sources/Vifty/AppModel+Control.swift`, `Sources/ViftyCore/HardwareService.swift`, `Sources/ViftyCore/DaemonWriteGate.swift`, `Sources/ViftyFanControlSafety/LocalFanHelperClient.swift`. +- Existing source tests: `Tests/ViftyCoreTests/AppModelFanControlTests.swift`, `Tests/ViftyCoreTests/FanControlCoordinatorTests.swift`, `Tests/ViftyCoreTests/FanControlArbiterTests.swift`, and `Tests/ViftyCoreTests/LocalFanHelperClientTests.swift`. +- Reviewed operator paths: `scripts/install-vifty.sh`, `scripts/vifty-helper-lifecycle.sh`, `scripts/repair-vifty-helper.sh`, `scripts/uninstall-vifty.sh`, and the `install-public-release`/`repair-helper` Make targets. +- Preserve historical evidence: `.build/lean-polish-20260907/`, `.build/vifty-recovery/`, and `docs/reviews/2026-09-07-lean-polish.md`. +- New continuation evidence: `.build/vifty-recovery/continuation-$STAMP/`, created by the first execution task and retained. +- New final review: `docs/reviews/2026-09-10-vifty-recovery.md`, created only after the recovery attempt has a result to record; it must contain verified claims and explicit non-claims. + +## Gate Order + +| Gate | Required result | If it fails | +|---|---|---| +| Baseline | Fresh read-only state captured and worktree preserved | Reconcile facts; do not assume the handoff is still current | +| Source | Current dirty head passes `make verify-full` | Debug the failing gate; no runtime mutation or cleanup | +| Trusted runtime | Exact public archive installed through reviewed transaction | Preserve receipts/logs; no direct copy or retry after an unsafe failure | +| Daemon | Canonical path runs, XPC responds, no LWCR/EX_CONFIG failure | Stop at launchd/BTM evidence and owner restart/approval gates | +| Auto | `diagnose --json` exits 0 with clean ownership and Auto readback | Do not clear marker or test manual modes | +| Fixed/Curve | Fresh receipt and daemon snapshot prove the requested mode/target | Preserve receipt; classify root cause before any source edit | +| Closure | Full verification, evidence review, diff review | Leave branch uncommitted and report the exact blocker | + +--- + +### Task 1: Capture a fresh read-only recovery baseline + +**Files:** +- Create: `.build/vifty-recovery/continuation-$STAMP/` (ignored evidence directory) +- Read only: `/Library/Application Support/ViftyMaintenanceEvidence/replacement-state-v1.json` +- Read only: `/Library/Application Support/ViftyMaintenanceEvidence/last-execution-v1.json` +- Read only: `/Users/reidar/Library/Application Support/Vifty/manual-control-active` +- Read only: `/Applications/Vifty.app` and `/Users/reidar/Applications/Vifty Recovery 14/Vifty.app` + +**Interfaces:** +- Produces: one timestamped evidence directory and a baseline that binds branch, SHA, selected app paths, daemon state, BTM state, hardware probe, marker metadata, disk space, and process state. +- Consumes: the handoff’s preservation boundaries and the root ledger as authoritative recovery state. + +- [ ] **Step 1: Confirm the inherited source state without modifying it.** + +Run: + +```sh +git status --short -b +git rev-parse HEAD +git diff --stat +df -h /System/Volumes/Data +``` + +Expected: branch `codex/lean-polish`, HEAD `e79bcab20ad7c9eda302fe4594bfa547b15f929a`, the intentional dirty paths remain present, and free space is at least 30 GiB. + +- [ ] **Step 2: Create one bounded evidence directory and export its path.** + +```sh +STAMP="$(date '+%Y%m%d-%H%M%S')" +export EVIDENCE_DIR="$PWD/.build/vifty-recovery/continuation-$STAMP" +mkdir -p "$EVIDENCE_DIR" +``` + +- [ ] **Step 3: Capture the daemon, BTM, signing, logs, diagnosis, probe, marker, disk, and process outputs independently.** + +Run each command independently and retain nonzero output as evidence: + +```sh +git status --short -b >"$EVIDENCE_DIR/git-status.txt" +git rev-parse HEAD >"$EVIDENCE_DIR/git-head.txt" +launchctl print system/tech.reidar.vifty.daemon >"$EVIDENCE_DIR/launchctl-daemon.txt" 2>&1 +sfltool dumpbtm >"$EVIDENCE_DIR/btm.txt" 2>&1 +codesign -dvvv /Applications/Vifty.app >"$EVIDENCE_DIR/app-codesign.txt" 2>&1 +codesign -dvvv /Applications/Vifty.app/Contents/MacOS/ViftyDaemon >"$EVIDENCE_DIR/daemon-codesign.txt" 2>&1 +/usr/bin/log show --style compact --last 30m --predicate 'process == "xpcproxy" OR process == "launchd" OR eventMessage CONTAINS[c] "tech.reidar.vifty.daemon"' >"$EVIDENCE_DIR/launchd-log.txt" 2>&1 +/Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json >"$EVIDENCE_DIR/diagnose.json" 2>"$EVIDENCE_DIR/diagnose.stderr" +/Applications/Vifty.app/Contents/MacOS/ViftyHelper probe >"$EVIDENCE_DIR/hardware-probe.txt" 2>&1 +/usr/bin/stat -f '%N %Sp %Su %Sg %Sf %m' '/Users/reidar/Library/Application Support/Vifty/manual-control-active' >"$EVIDENCE_DIR/manual-marker-stat.txt" 2>&1 +df -h /System/Volumes/Data >"$EVIDENCE_DIR/disk-space.txt" +pgrep -alf 'Vifty|ViftyDaemon' >"$EVIDENCE_DIR/vifty-processes.txt" 2>&1 +``` + +Record the `viftyctl diagnose` exit status beside `diagnose.json`; an exit 75 is expected for the inherited blocked state and is evidence, not a reason to bypass the gate. + +- [ ] **Step 4: Verify preserved recovery artifacts are still present and unchanged.** + +```sh +ls -lO '/Library/Application Support/ViftyMaintenanceEvidence/replacement-state-v1.json' \ + '/Library/Application Support/ViftyMaintenanceEvidence/last-execution-v1.json' \ + '/Users/reidar/Library/Application Support/Vifty/manual-control-active' +find '/Library/Application Support/ViftyMaintenanceEvidence/ReplacementTransactions/9c0fc2cd-f029-4014-a735-26e44d3d096a' -maxdepth 1 -print +``` + +Expected: the root ledger and transaction remain available; the public mirror is not treated as rollback authority; the marker is not removed. + +### Task 2: Verify the current dirty source tree before runtime mutation + +**Files:** +- Read only: `Makefile`, all inherited modified source/test/script paths, and `docs/reviews/2026-09-07-lean-polish.md` +- Create: `$EVIDENCE_DIR/verify-full.log` + +**Interfaces:** +- Consumes: current dirty HEAD and the existing SwiftPM/Make verification contract. +- Produces: current-tree verification evidence; no source changes. + +- [ ] **Step 1: Ensure no competing SwiftPM or Xcode verification job is running.** + +```sh +pgrep -alf 'swift-build|swiftc|xctest|xcodebuild' || true +``` + +Stop only an owned stale verification process after checking its PID and open files; do not interrupt an unrelated user build. + +- [ ] **Step 2: Run the cheap diff gate.** + +```sh +git diff --check +``` + +Expected: exit 0. Any whitespace error is fixed only in the inherited intended diff and rechecked; unrelated changes are not reformatted. + +- [ ] **Step 3: Run the full current-tree gate once.** + +```sh +set -o pipefail +make verify-full SWIFT_BUILD_PATH="$PWD/.build" 2>&1 | tee "$EVIDENCE_DIR/verify-full.log" +``` + +Expected: exit 0 with the current helper-lifecycle extension included. Retain the existing historical logs; do not overwrite them. + +- [ ] **Step 4: If the gate fails, enter systematic debugging instead of installing or cleaning.** + +Classify the first failing command, reproduce only that command, inspect the relevant current diff and caller chain, and record the failure in the continuation evidence. Do not proceed to helper recovery until the current source gate is green or the user explicitly chooses to continue with a named source blocker. + +### Task 3: Select and install the canonical signed runtime + +**Files:** +- Consume: `.build/vifty-recovery/Vifty-v1.4.5.zip` +- Use without editing: `scripts/install-vifty.sh`, `scripts/vifty-helper-lifecycle.sh`, `scripts/repair-vifty-helper.sh`, `Makefile` +- Preserve: `/Applications/Vifty.app`, `/Users/reidar/Applications/Vifty.app`, and root replacement evidence +- Create: `$EVIDENCE_DIR/public-release-summary.json`, `$EVIDENCE_DIR/public-install.log` + +**Interfaces:** +- Input: exact archive SHA `13fa763cbfdca3e77fcf6f657df6d51b32e19a4d25dd17a79614635fe844b0d5`. +- Output: `/Applications/Vifty.app` is the selected canonical public-release path only if the reviewed installer completes and its post-swap evidence passes. + +- [ ] **Step 1: Recheck the archive identity and public artifact trust.** + +```sh +PUBLIC_ARCHIVE="$PWD/.build/vifty-recovery/Vifty-v1.4.5.zip" +export PUBLIC_ARCHIVE +shasum -a 256 "$PUBLIC_ARCHIVE" | tee "$EVIDENCE_DIR/public-archive-sha256.txt" +scripts/verify-release-artifact.sh \ + --artifact "$PUBLIC_ARCHIVE" \ + --release-version 1.4.5 \ + --team-id X88J3853S2 \ + --summary "$EVIDENCE_DIR/public-release-summary.json" +``` + +Expected: the checksum matches exactly and the verifier proves the archive’s Developer ID identity, Team ID, notarization, stapling, Gatekeeper result, bundle contents, and manifest binding. + +- [ ] **Step 2: Run the reviewed public-release installer.** + +```sh +set -o pipefail +make install-public-release PUBLIC_RELEASE_ARCHIVE="$PUBLIC_ARCHIVE" 2>&1 | tee "$EVIDENCE_DIR/public-install.log" +``` + +Do not copy the bundle manually, use `cp -p`, pass a direct app/URL/SHA override, or edit the root ledger. + +- [ ] **Step 3: Classify installer failure before any retry.** + +If the installer reports a protocol mismatch, receipt failure, unknown active authority, or any result other than the reviewed `HELPER_UNREACHABLE` admission, stop and preserve the transaction evidence. Do not replay finish/release-lock, invoke a raw unlock, or retry the helper script. The exact public-helper recovery extension is admitted only for ordinary uninstall on the exact `HELPER_UNREACHABLE` path and must remain inside the reviewed lifecycle. + +- [ ] **Step 4: Verify the selected app identity after a successful install.** + +```sh +codesign -dvvv /Applications/Vifty.app >"$EVIDENCE_DIR/selected-app-codesign.txt" 2>&1 +codesign -dvvv /Applications/Vifty.app/Contents/MacOS/ViftyDaemon >"$EVIDENCE_DIR/selected-daemon-codesign.txt" 2>&1 +shasum -a 256 /Applications/Vifty.app/Contents/MacOS/ViftyDaemon >"$EVIDENCE_DIR/selected-daemon-sha256.txt" +``` + +Expected: the selected app and daemon are the verified signed bundle, with Team ID `X88J3853S2`; the ad-hoc `/Applications` build is no longer the trusted runtime. + +### Task 4: Reconcile native registration and launchd without weakening trust + +**Files:** +- Use: `/Applications/Vifty.app`, `SMAppService` through the app’s helper-maintenance UI, `scripts/repair-vifty-helper.sh` +- Read only: `launchctl print`, `sfltool dumpbtm`, unified logs, installed plist/helper metadata +- Do not modify: `sfltool` state directly, launch constraints, TeamID allowlists, or root evidence ledgers + +**Interfaces:** +- Consumes: the canonical signed app selected in Task 3. +- Produces: one daemon registration bound to `/Applications/Vifty.app/Contents/MacOS/ViftyDaemon`, with a responding XPC service and no duplicate-path ambiguity. + +- [ ] **Step 1: Open the canonical app and use its native Install Helper/Repair Helper action.** + +```sh +open -a /Applications/Vifty.app +``` + +Complete only the owner-authenticated native action presented by Vifty. If the reviewed operator wrapper is required by that flow, use only: + +```sh +make repair-helper REPAIR_HELPER_APP=/Applications/Vifty.app +``` + +Do not invoke `vifty-helper-lifecycle.sh` with hand-written replacement-phase arguments. + +- [ ] **Step 2: Preserve the user’s background-login intent.** + +Do not automatically toggle Login Items. If native UI requires an owner-authenticated disable/re-enable refresh, record the original disposition first and restore that same disposition; do not use `sfltool resetbtm`. + +- [ ] **Step 3: Honor one owner restart gate if launchd requests it.** + +Before the restart, capture `launchctl print`, `sfltool dumpbtm`, and the latest unified log output into `$EVIDENCE_DIR`. Restart once through the normal macOS UI, then repeat the read-only captures. Do not attempt another registration cycle before checking the new state. + +- [ ] **Step 4: Verify daemon identity and reachability.** + +```sh +launchctl print system/tech.reidar.vifty.daemon >"$EVIDENCE_DIR/launchctl-after-registration.txt" 2>&1 +sfltool dumpbtm >"$EVIDENCE_DIR/btm-after-registration.txt" 2>&1 +/usr/bin/log show --style compact --last 15m --predicate 'process == "xpcproxy" OR process == "launchd" OR eventMessage CONTAINS[c] "tech.reidar.vifty.daemon"' >"$EVIDENCE_DIR/launchd-after-registration.log" 2>&1 +/Applications/Vifty.app/Contents/MacOS/viftyctl status --json >"$EVIDENCE_DIR/status-after-registration.json" 2>&1 +``` + +Pass only when launchd has no `needs LWCR update`, no repeated `78: EX_CONFIG` spawn failure, the daemon process is running, `status --json` can obtain daemon state, and the executable path resolves to the selected canonical bundle. A duplicate same-ID path or another launch-constraint rejection is a blocker; do not reset BTM automatically. + +### Task 5: Prove clean Auto state and remove the stale marker only through the owner path + +**Files:** +- Use: `/Applications/Vifty.app` UI and daemon-backed `viftyctl` read-only commands +- Preserve until proof: `/Users/reidar/Library/Application Support/Vifty/manual-control-active` +- Create: `$EVIDENCE_DIR/auto-recovery-*` + +**Interfaces:** +- Consumes: a responding canonical daemon. +- Produces: diagnosis exit 0, explicit Auto/System readback, no active manual owner, and no stale marker left by an unverified operation. + +- [ ] **Step 1: Run the readiness gate before touching manual controls.** + +```sh +/Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json >"$EVIDENCE_DIR/diagnose-before-auto.json" 2>"$EVIDENCE_DIR/diagnose-before-auto.stderr" +AUTO_DIAGNOSE_EXIT=$? +printf '%s\n' "$AUTO_DIAGNOSE_EXIT" >"$EVIDENCE_DIR/diagnose-before-auto.exit" +``` + +Continue only when the exit is 0 and the JSON contains `daemonControlPathReady=true`, `daemonStatusAvailable=true`, `manualControlActive=false`, `safeToRequestCooling=true`, an empty `coolingBlockerIDs` array, and a fresh daemon snapshot. If it remains blocked, do not test Fixed/Curve and do not remove the marker. + +- [ ] **Step 2: Apply Auto through the Vifty UI and wait for a fresh poll.** + +Select Auto and use the normal Apply/Restore Auto action. Let the app complete its fresh snapshot and ownership confirmation. Do not invoke `ViftyHelper auto` or a raw SMC command. + +- [ ] **Step 3: Confirm Auto from independent read-only evidence.** + +```sh +/Applications/Vifty.app/Contents/MacOS/viftyctl status --json >"$EVIDENCE_DIR/status-after-auto.json" +/Applications/Vifty.app/Contents/MacOS/ViftyHelper probe >"$EVIDENCE_DIR/probe-after-auto.txt" 2>&1 +/Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json >"$EVIDENCE_DIR/diagnose-after-auto.json" 2>"$EVIDENCE_DIR/diagnose-after-auto.stderr" +``` + +Expected: both controllable fans are freshly confirmed Auto/System-managed, ownership is clear, `manualControlActive=false`, and the daemon reports no recovery blocker. + +- [ ] **Step 4: Verify marker retirement through the application path.** + +The marker may disappear only as a consequence of the verified daemon-owned Auto restoration. If it remains, keep it and record its metadata; do not `rm` it manually. + +### Task 6: Supervised Fixed RPM and Temperature Curve acceptance + +**Files:** +- Use: `/Applications/Vifty.app` UI and the responding daemon +- Read only: `viftyctl status --json`, `viftyctl audit --limit 20 --json`, `ViftyHelper probe`, app/daemon logs +- Create: `$EVIDENCE_DIR/fixed-*`, `$EVIDENCE_DIR/curve-*` + +**Interfaces:** +- Fixed path: `AppModel.applyCurrentModeSelection()` → coordinator manual batch → daemon/XPC → `FanControlArbiter` → `LocalFanHelperClient.apply(_:fan:)`. +- Curve path: the same transaction path after `FanControlCoordinator` resolves the selected sensor/curve to bounded fixed-RPM targets. +- Acceptance output: fresh `FanMutationReceipt`/audit evidence plus fresh daemon snapshot; a successful write call without readback is not acceptance. + +- [ ] **Step 1: Capture the starting Auto state and fan bounds.** + +Record the current fan IDs, minimum/maximum RPM, hardware mode, target RPM, selected sensor, and snapshot timestamp from the UI/daemon evidence. Choose a Fixed target inside every controllable fan’s trusted range; do not invent a raw RPM outside the reported bounds. + +- [ ] **Step 2: Apply Fixed RPM through the UI.** + +Select Fixed RPM, apply the bounded target, wait for a fresh daemon snapshot, and record the full mutation receipt or audit event. Pass only when every expected controllable fan reports `Forced`, every target equals the requested/clamped value, and the final receipt confirms `Ftst` disabled. A UI label or successful button press is insufficient. + +- [ ] **Step 3: Restore Auto immediately after Fixed acceptance.** + +Use the UI’s Restore Auto action, wait for a fresh snapshot, and record explicit Auto/System mode and `Ftst=0` confirmation. If restoration fails or is not freshly confirmed, stop all further testing. + +- [ ] **Step 4: Apply Temperature Curve through the UI.** + +Start from the verified Auto state, choose one valid reported temperature sensor, apply the existing valid curve profile, and record the current temperature plus the expected interpolated/clamped RPM. Confirm the daemon snapshot and UI state agree for every controllable fan. Exercise the edited-profile path once only if the acceptance target includes profile editing; do not add a new profile feature. + +- [ ] **Step 5: Restore Auto after Curve acceptance and capture final evidence.** + +```sh +/Applications/Vifty.app/Contents/MacOS/viftyctl status --json >"$EVIDENCE_DIR/curve-status.json" +/Applications/Vifty.app/Contents/MacOS/viftyctl audit --limit 20 --json >"$EVIDENCE_DIR/curve-audit.json" +/Applications/Vifty.app/Contents/MacOS/ViftyHelper probe >"$EVIDENCE_DIR/curve-probe.txt" 2>&1 +``` + +Expected: explicit Auto readback after restoration, no active manual owner, and no stale marker without an owner-approved reason. + +### Task 7: Conditional receipt-backed source fix + +**Files:** +- Inspect first: `Sources/Vifty/AppModel+Control.swift`, `Sources/ViftyCore/HardwareService.swift`, `Sources/ViftyCore/DaemonWriteGate.swift`, `Sources/ViftyFanControlSafety/LocalFanHelperClient.swift` +- Test first in the owner of the invariant: `Tests/ViftyCoreTests/LocalFanHelperClientTests.swift`, `Tests/ViftyCoreTests/FanControlCoordinatorTests.swift`, `Tests/ViftyCoreTests/FanControlArbiterTests.swift`, or `Tests/ViftyCoreTests/AppModelFanControlTests.swift` +- Do not modify: release metadata or trusted installed bundles as part of this task + +**Interfaces:** +- Input: one fresh failure with `FanMutationError.code`, `primaryError`, `cleanupErrors`, and `FanMutationReceipt` fields captured. +- Candidate shared invariants: `LocalFanHelperClient.apply(_:fan:)` must end Fixed with `observedMode == .forced`, exact `observedTargetRPM`, and `forceTestDisabled == true`; Auto cleanup must have `recoveryConfirmed == true`; coordinator ownership must be confirmed by fresh daemon readback. +- Output: one minimal source change only if the evidence identifies a source defect, plus a regression test that fails before the change and passes after it. + +- [ ] **Step 1: Classify the failed acceptance without retrying blindly.** + +Use the receipt to distinguish mode reclaim/registration, target encoding or clamping, Ftst cleanup, daemon/XPC stale-state, and UI/coordinator publication errors. Trace from `AppModel.applyCurrentModeSelection()` through `FanControlCoordinator.applyManualBatch` and the daemon/arbiter boundary before editing. + +- [ ] **Step 2: Add one deterministic failing fixture for the identified invariant.** + +Place the fixture in the existing test owner. For a low-level mismatch, extend `LocalFanHelperClientTests` with the exact mode/target/Ftst readback sequence. For coordinator or ownership publication, extend `FanControlCoordinatorTests`, `FanControlArbiterTests`, or `AppModelFanControlTests` with the exact observed state. Do not add a new abstraction or dependency. + +- [ ] **Step 3: Run the focused failure before implementing the fix.** + +```sh +swift test --scratch-path "$PWD/.build" \ + --filter 'LocalFanHelperClientTests|FanControlCoordinatorTests|FanControlArbiterTests|AppModelFanControlTests' +``` + +Expected: the new fixture fails for the receipt-backed reason, not because of an unrelated build or environment failure. + +- [ ] **Step 4: Implement the smallest root-cause fix in the shared path.** + +Preserve mode readback, exact target readback, `Ftst` cleanup, Auto rollback, ownership confirmation, daemon write gates, and all existing error codes. Do not make the UI hide a mismatch, weaken a final check, add retry loops outside the existing bounded unlock path, or install a local ad-hoc build over the trusted public runtime. + +- [ ] **Step 5: Run focused tests, then repeat Tasks 2, 5, and 6.** + +The source fix must pass the focused suite and the full current-tree gate before any new hardware claim. A local/ad-hoc source build is separate evidence and must not be described as the public v1.4.5 artifact. + +### Task 8: Evidence review, lean-polish scope closure, and integration decision + +**Files:** +- Create: `docs/reviews/2026-09-10-vifty-recovery.md` +- Preserve: `docs/reviews/2026-09-07-lean-polish.md` and all `.build/vifty-recovery/` evidence +- Read only until all gates pass: complete `git diff`, source/test/script inventories, release metadata, and safety contracts + +**Interfaces:** +- Consumes: the continuation evidence directory and all source/runtime/hardware gate results. +- Produces: an evidence-backed review with verified claims, non-claims, blocker IDs, exact app/archive identity, and the final integration recommendation. + +- [ ] **Step 1: Re-run the full verification after any source edit or lifecycle change.** + +```sh +git diff --check +set -o pipefail +make verify-full SWIFT_BUILD_PATH="$PWD/.build" 2>&1 | tee "$EVIDENCE_DIR/verify-full-final.log" +``` + +Expected: exit 0 on the final current tree. Do not claim the earlier 2,031-case result as current proof if the final run is missing. + +- [ ] **Step 2: Review the complete dirty diff for safety and scope.** + +```sh +git status --short -b +git diff --stat +git diff -- Makefile Sources Tests scripts docs/reviews +``` + +Confirm no raw fan-write path, release gate, TeamID check, launch-constraint check, Auto restoration, readback invariant, accessibility rule, or evidence-provenance boundary was weakened. + +- [ ] **Step 3: Keep optional cleanup deferred unless measured and independent.** + +Do not add shared privacy scanners, more subprocess abstractions, or further Codex session optimization during recovery. Add one only after the runtime and hardware gates pass, a bounded measurement demonstrates a real cost, and the change has its own focused test and reviewable diff. + +- [ ] **Step 4: Write the dated review with explicit claims and non-claims.** + +The review must state the final branch/SHA, full verification result, selected app/archive identity, launchd/BTM outcome, diagnosis exit/fields, Auto evidence, Fixed evidence, Curve evidence, Ftst result, marker outcome, and every unproven item. It must explicitly state whether any raw SMC write, unsafe rollback, global BTM reset, release tag, deployment, or public release mutation occurred. + +- [ ] **Step 5: Decide integration only after the review.** + +If all done criteria pass, review the complete diff and prepare the branch for user-approved commit and code review. If any runtime or hardware gate remains blocked, leave the inherited worktree intact, do not create a release tag or deploy, and hand off the exact blocker and evidence path. + +## Done Criteria + +- [ ] The current dirty head passes `make verify-full SWIFT_BUILD_PATH="$PWD/.build"`. +- [ ] Exactly one canonical signed app path is selected, and the public archive identity is recorded. +- [ ] The launchd daemon runs without LWCR/launch-constraint/EX_CONFIG failure and responds over XPC. +- [ ] `viftyctl diagnose --json` exits 0 with `daemonControlPathReady=true`, `daemonStatusAvailable=true`, `manualControlActive=false`, `safeToRequestCooling=true`, and no cooling blockers. +- [ ] Explicit Auto restoration is freshly confirmed for every controllable fan, with `Ftst=0` and no unexplained marker. +- [ ] Fixed RPM succeeds through the UI/daemon path with Forced mode, exact bounded targets, fresh readback, and final `Ftst=0`. +- [ ] Temperature Curve succeeds through the UI/daemon path with correct sensor/curve resolution, fresh readback, and explicit Auto restoration. +- [ ] The final review records evidence and non-claims; prior-version or local-test evidence is not promoted into current public-release or hardware claims. +- [ ] No raw SMC write, unsafe rollback, global BTM reset, release tag, merge, deployment, or public-release mutation occurred without the required explicit approval. diff --git a/docs/superpowers/plans/2026-09-12-vifty-one-shot-recovery.md b/docs/superpowers/plans/2026-09-12-vifty-one-shot-recovery.md new file mode 100644 index 00000000..dc0855ac --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-vifty-one-shot-recovery.md @@ -0,0 +1,452 @@ +# Vifty One-Shot Recovery and Repair Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to execute this plan task-by-task. Steps use checkbox syntax for tracking. Do not dispatch subagents: the work is serialized privileged state, and the global Vifty rules forbid treating parallel observations as one recovery transaction. + +**Goal:** Convert the current blocked ad-hoc Vifty installation into the exact notarized v1.4.5 runtime, repair its helper and launchd registration, prove daemon-backed Auto ownership, and perform only supervised Fixed/Curve/Auto acceptance while preserving every inherited artifact and dirty source change. + +**Architecture:** Run one sequential operator flow with a private evidence directory and fail-closed checkpoints. Request the 1Password desktop-app authorization immediately, then keep all long-running source checks non-mutating and hermetic; only after they pass enter the native administrator boundary for helper teardown, reversible old-app quarantine, public installation, and helper registration. Reuse install-vifty.sh, vifty-helper-lifecycle.sh, uninstall-vifty.sh, repair-vifty-helper.sh, the existing daemon receipts, and the app UI; do not add a second fan-control or replacement implementation. + +**Tech Stack:** Swift Package Manager, Swift/XCTest, Bash, Ruby evidence/contract scripts, macOS launchd/SMAppService/BTM, codesign, Gatekeeper, notarized Developer ID archive, and 1Password CLI desktop integration. + +**Spec:** docs/superpowers/plans/2026-09-10-vifty-recovery-lean-polish.md, docs/reviews/2026-09-10-vifty-recovery.md, docs/trust-model.md, docs/support-triage.md, docs/safe-agent-cooling.md, and the repository/global AGENTS.md files. + +## Global Constraints + +- Preserve branch codex/lean-polish, inherited dirty paths, existing recovery ledgers, manual-control markers, and prior evidence; never reset, clean, stash, overwrite, or delete them. +- Bind this run to current HEAD e79bcab20ad7c9eda302fe4594bfa547b15f929a unless a new plan is explicitly written for a different tree. +- Use the exact public archive /Users/reidar/Projectos/Vifty/.build/vifty-recovery/Vifty-v1.4.5.zip with SHA-256 13fa763cbfdca3e77fcf6f657df6d51b32e19a4d25dd17a79614635fe844b0d5. +- Keep /Applications/Vifty.app as the canonical destination. Do not install a second public copy, use a URL/SHA override, or fall back to ~/Applications. +- Ask 1Password before any long command by running op signin --account my.1password.com; use desktop integration, never print or persist a session token, and never read a password or secret into a shell variable. +- 1Password authorization is not Vifty fan/helper authorization. The native administrator prompt from the reviewed lifecycle script is a separate boundary; do not pipe a password into it and do not call sudo. +- Do not use `sudo -v`, an AppleScript password argument, a shell prompt, or any credential-cache workaround to pre-authorize macOS. Let the reviewed lifecycle call show the native prompt only after all non-mutating gates are green. +- Never background, multiplex, or bury a prompt-producing lifecycle/install command behind a long build. Run the prompt window in the foreground with the terminal visible and all evidence paths already prepared. +- Run source tests with GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null so host-wide Git SSH signing through 1Password cannot make temporary fixture commits nondeterministic. This does not modify the user's Git configuration. +- Do not call ViftyHelper setFixed, ViftyHelper auto, raw SMC tools, direct RPM writes, unguarded viftyctl prepare, sfltool resetbtm, or hand-written lifecycle replacement phases. +- Do not weaken the installer provenance gate. The existing ad-hoc release app must be safely unregistered and reversibly quarantined before the public installer is allowed to see an absent destination. +- Do not request cooling or perform manual fan writes while diagnose --json is blocked, safeToRequestCooling is false, daemonControlPathReady is false, manualControlActive is true, or blocker IDs are non-empty. +- Stop on unknown/active helper authority, malformed or expired receipts, protocol mismatch without the exact approved fallback, changed bundle identity, duplicate active BTM registration, or any installer exit that is not explicitly classified by its evidence. +- No release tag, merge, deployment, public release mutation, or commit is part of this repair. + +## Why the first interactive boundary is different + +The previous exact-archive install correctly stopped at exit 75 because /Applications/Vifty.app is an ad-hoc v1.4.5 build 13 with no Team ID. The installer will not treat that bundle as an authenticated predecessor merely because its version matches the public archive. Retrying the same command, signing the old bundle ad hoc again, editing the ledger, or resetting BTM would bypass the trust model. + +The safe one-shot has two owner boundaries: + +1. 1Password desktop-app authorization is requested immediately, before disk/build work. It is idempotent and completes quickly if already authorized. +2. The Vifty lifecycle invokes a native macOS administrator prompt when it tears down or registers the privileged helper. That prompt cannot be safely pre-satisfied through 1Password or sudo; the operator must remain present until the prompt burst has completed. After that, the controller can run unattended. + +The source gate runs before the administrator boundary unless an exact, fresh green gate is already bound to the same HEAD, dirty tree, and archive. This prevents a 30-minute test failure from occurring after helper teardown. + +## Administrator-prompt timing contract + +The execution controller must make the macOS password request a short, explicit handoff: + +1. Complete 1Password authorization, the read-only baseline, the cheap binding checks, and—only if the prior green evidence is not reusable—the hermetic source/archive gate. +2. Create the evidence directory, open the log files, and verify the exact archive and expected destination state before invoking any lifecycle command. +3. Send one operator-facing notice: `Preflight is green. Stay at the Mac; the Vifty administrator prompt is next.` Do not ask for the password in chat or accept it in the terminal. +4. Run the helper transition and public install in the foreground. The first native prompt occurs when the reviewed lifecycle enters its root boundary; the public replacement transaction may show another prompt during its prepare/finish boundary. Type the administrator password directly into each native macOS dialog as soon as it appears. Do not authorize unrelated dialogs. +5. Do not start another build, test, network request, or UI smoke action while the prompt-producing command is active. If a prompt is cancelled, unexpected, or ambiguous, stop and preserve the transaction receipt; do not retry automatically. + +This is the fastest safe timing because it removes the long wait after a password prompt without spending administrator authorization before the run is known to be ready. A successful public install owns the normal registration lifecycle; a routine extra `make repair-helper` call is intentionally avoided because it would create another privileged transition. + +--- + +### Task 0: Start the one-shot session and request 1Password immediately + +**Files:** +- Create: .build/vifty-recovery/one-shot-$STAMP/ (ignored evidence only) +- Read only: /Users/reidar/Projectos/Vifty/AGENTS.md, /Users/reidar/Projectos/AGENTS.md +- Do not read: vault item contents, passwords, session tokens, or SSH private keys + +**Interfaces:** +- Consumes: the operator's current terminal and 1Password desktop integration. +- Produces: an authenticated 1Password CLI session check and an evidence directory that records only passed/failed, not account secrets. + +- [ ] Step 1: Launch from the repository and set the non-secret account filter. + + cd /Users/reidar/Projectos/Vifty + export OP_ACCOUNT="${OP_ACCOUNT:-my.1password.com}" + +- [ ] Step 2: Ask for 1Password authorization before any long-running work. + + op signin --account "$OP_ACCOUNT" + op whoami --account "$OP_ACCOUNT" >/dev/null + + op signin is idempotent and uses the 1Password desktop app when integration is enabled. If it reports that app integration is disabled, open 1Password, enable Settings → Developer → Integrate with 1Password CLI, then rerun exactly the same command. Do not use op account add, paste a secret key into the terminal, or store the returned session token. + +- [ ] Step 3: Create the private evidence root only after the auth check passes. + + umask 077 + STAMP="$(date '+%Y%m%d-%H%M%S')" + export EVIDENCE_DIR="$PWD/.build/vifty-recovery/one-shot-$STAMP" + mkdir -p "$EVIDENCE_DIR" + printf '%s\n' "onepassword=authorized" >"$EVIDENCE_DIR/authorization-status.txt" + + Keep `umask 077` for the evidence root, but do not leak it into Swift/Ruby fixture processes. The source-gate step below creates its log before entering a `umask 022` subshell, then restores mode `0600` after the command. This preserves private evidence without changing the fixture contract for simulated root-owned `0755` directories. + +- [ ] Step 4: Set and record the run contract without recording secrets. + + export PUBLIC_ARCHIVE="$PWD/.build/vifty-recovery/Vifty-v1.4.5.zip" + export PUBLIC_ARCHIVE_SHA256="13fa763cbfdca3e77fcf6f657df6d51b32e19a4d25dd17a79614635fe844b0d5" + { + date -u '+%Y-%m-%dT%H:%M:%SZ' + git rev-parse HEAD + printf '%s\n' "$PUBLIC_ARCHIVE_SHA256" + } >"$EVIDENCE_DIR/run-contract.txt" + +### Task 1: Capture a fresh read-only baseline and bind the dirty tree + +**Files:** +- Create: $EVIDENCE_DIR/git-status.txt, $EVIDENCE_DIR/launchctl-before.txt, $EVIDENCE_DIR/btm-before.txt, $EVIDENCE_DIR/diagnose-before.json, $EVIDENCE_DIR/diagnose-before.exit, $EVIDENCE_DIR/app-before-codesign.txt, $EVIDENCE_DIR/disk-before.txt, $EVIDENCE_DIR/processes-before.txt +- Read only: /Applications/Vifty.app, /Applications/Vifty Recovery 14/Vifty.app, /Library/Application Support/ViftyMaintenanceEvidence/, /Users/reidar/Library/Application Support/Vifty/ + +**Interfaces:** +- Consumes: the run contract from Task 0. +- Produces: a timestamped pre-mutation snapshot and an explicit decision that the current state is still the known blocked state. + +- [ ] Step 1: Enforce the expected source identity and free-space guardrail. + + test "$(git rev-parse HEAD)" = "e79bcab20ad7c9eda302fe4594bfa547b15f929a" + git status --short -b >"$EVIDENCE_DIR/git-status.txt" + git diff --stat >"$EVIDENCE_DIR/git-diff-stat.txt" + git diff --check + df -h /System/Volumes/Data | tee "$EVIDENCE_DIR/disk-before.txt" + + Stop if free space is below 30 GiB or if HEAD differs. Do not silently retarget the operation to a newer or different dirty tree. + +- [ ] Step 2: Capture the selected app, duplicate app paths, daemon, BTM, and preserved evidence. + + find /Applications "$HOME/Applications" -maxdepth 3 -type d -name Vifty.app -print 2>/dev/null | sort >"$EVIDENCE_DIR/app-paths-before.txt" + codesign -dvvv /Applications/Vifty.app >"$EVIDENCE_DIR/app-before-codesign.txt" 2>&1 || true + launchctl print system/tech.reidar.vifty.daemon >"$EVIDENCE_DIR/launchctl-before.txt" 2>&1 || true + sfltool dumpbtm >"$EVIDENCE_DIR/btm-before.txt" 2>&1 || true + pgrep -alf '(^|/)(Vifty|ViftyDaemon|ViftyHelper|viftyctl)( |$)' >"$EVIDENCE_DIR/processes-before.txt" 2>&1 || true + ls -lO \ + '/Library/Application Support/ViftyMaintenanceEvidence/replacement-state-v1.json' \ + '/Library/Application Support/ViftyMaintenanceEvidence/last-execution-v1.json' \ + '/Users/reidar/Library/Application Support/Vifty/manual-control-active' \ + >"$EVIDENCE_DIR/preserved-artifacts-before.txt" 2>&1 || true + +- [ ] Step 3: Run read-only diagnosis and preserve its exit code. + + set +e + /Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json \ + >"$EVIDENCE_DIR/diagnose-before.json" \ + 2>"$EVIDENCE_DIR/diagnose-before.stderr" + printf '%s\n' "$?" >"$EVIDENCE_DIR/diagnose-before.exit" + set -e + + An exit 75 with state: blocked, recommendedRecoveryAction: repairHelper, or daemonControlPathReady: false is expected at this stage. It is a stop-before-cooling signal, not permission to invoke a fan command. + +### Task 2: Prove the current source and exact public archive before any system mutation + +**Files:** +- Read only: all inherited dirty source/test/script files and the exact archive. +- Create: $EVIDENCE_DIR/verify-full-hermetic.log, $EVIDENCE_DIR/public-release-summary.json, $EVIDENCE_DIR/public-release-verifier.log, $EVIDENCE_DIR/archive-sha256.txt + +**Interfaces:** +- Consumes: current HEAD and the Task 1 evidence binding. +- Produces: a source gate and archive gate that authorize the later administrator boundary. + +- [ ] Step 1: Verify the archive identity and release trust. + + stat -f '%N %z bytes' "$PUBLIC_ARCHIVE" >"$EVIDENCE_DIR/archive-stat.txt" + shasum -a 256 "$PUBLIC_ARCHIVE" | tee "$EVIDENCE_DIR/archive-sha256.txt" + test "$(awk '{print $1}' "$EVIDENCE_DIR/archive-sha256.txt")" = "$PUBLIC_ARCHIVE_SHA256" + scripts/verify-release-artifact.sh \ + --artifact "$PUBLIC_ARCHIVE" \ + --release-version 1.4.5 \ + --team-id X88J3853S2 \ + --summary "$EVIDENCE_DIR/public-release-summary.json" \ + 2>&1 | tee "$EVIDENCE_DIR/public-release-verifier.log" + + Expected: notarized Developer ID, Team ID X88J3853S2, stapling, Gatekeeper acceptance, exact content binding, and the pinned SHA all pass. + +- [ ] Step 2: Run the full source gate with host Git signing isolated. + + : >"$EVIDENCE_DIR/verify-full-hermetic.log" + chmod 600 "$EVIDENCE_DIR/verify-full-hermetic.log" + ( + umask 022 + set -o pipefail + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_SYSTEM=/dev/null \ + make verify-full SWIFT_BUILD_PATH="$PWD/.build" \ + 2>&1 | tee "$EVIDENCE_DIR/verify-full-hermetic.log" + ) + chmod 600 "$EVIDENCE_DIR/verify-full-hermetic.log" + + Expected: exit 0, 2,037 XCTest cases passing, release bundle/codesign/plist/schema checks passing, and all Ruby trust, installer, helper-lifecycle, governance, and UI evidence suites passing. The empty Git config is a test-isolation boundary only; do not alter ~/.gitconfig. + +- [ ] Step 3: Bind the green gate to the exact dirty tree. + + git rev-parse HEAD >"$EVIDENCE_DIR/verified-head.txt" + git status --short >"$EVIDENCE_DIR/verified-status.txt" + git diff --check + + If any source, script, test, or plan file changes after the gate, invalidate the gate and rerun it before the administrator boundary. + +### Task 3: Safely transition the existing ad-hoc installation + +**Files:** +- Use only: scripts/uninstall-vifty.sh, scripts/vifty-helper-lifecycle.sh, Makefile target uninstall-helper +- Preserve: /Applications/Vifty.app and its exact tree under $EVIDENCE_DIR/previous-install/ +- Create: $EVIDENCE_DIR/uninstall-helper.log, $EVIDENCE_DIR/launchctl-after-uninstall.txt, $EVIDENCE_DIR/btm-after-uninstall.txt, $EVIDENCE_DIR/previous-app-content-manifest.txt + +**Interfaces:** +- Consumes: the green source/archive gates. +- Produces: a verified helper-unregistered state and a reversible quarantine precondition for the strict public installer. + +- [ ] Step 1: Capture the old app's content binding before moving it. + + mkdir -p "$EVIDENCE_DIR/previous-install" + ruby scripts/release-candidate-inventory.rb public-tree-sha256 \ + --app /Applications/Vifty.app \ + >"$EVIDENCE_DIR/previous-app-content-manifest.txt" + + This is an inventory only; it does not establish trusted public-release provenance. + +- [ ] Step 2: Use the reviewed helper-uninstall path and approve the native administrator prompt. + + set -o pipefail + UNINSTALL_HELPER_APP=/Applications/Vifty.app \ + make uninstall-helper 2>&1 | tee "$EVIDENCE_DIR/uninstall-helper.log" + + Run this in the foreground only after Task 2 is green. The lifecycle script must produce its own fresh receipt/offline Auto proof, disable and verify the exact daemon label offline, and finish SMAppService unregistration. Type the password in the native macOS dialog immediately when it appears. Do not replace this with sudo, `sudo -v`, direct launchctl, raw lifecycle phase arguments, or an edited maintenance report. + + If the predecessor is an ad-hoc bundle and the reviewed lifecycle classifies it as unreachable before authorization, preserve that failed record and do not move the predecessor. Use the exact signed public recovery bundle at `/Users/reidar/Applications/Vifty Recovery 14/Vifty.app` only after independently confirming its Developer ID Team ID and the pinned public recovery-helper SHA (`4c467d99f7e59c2727f0e1a9b13de81772741d269b560ce6ca9fb605782f0d0f`), but invoke the current reviewed lifecycle so its explicit public-helper fallback is present: `UNINSTALL_HELPER_APP=/Users/reidar/Applications/Vifty Recovery 14/Vifty.app make uninstall-helper`. This is the reviewed offline recovery-helper path for the active public registration; it is not a raw phase invocation and must still reach the same native administrator boundary. Do not use an ad-hoc recovery bundle, a different Vifty app path, or a second retry after a 75/76 result. + +- [ ] Step 3: Verify that helper authority is absent before moving the old app. + + launchctl print system/tech.reidar.vifty.daemon \ + >"$EVIDENCE_DIR/launchctl-after-uninstall.txt" 2>&1 || true + sfltool dumpbtm >"$EVIDENCE_DIR/btm-after-uninstall.txt" 2>&1 || true + test ! -e /Library/PrivilegedHelperTools/tech.reidar.vifty.helper + test ! -e /Library/LaunchDaemons/tech.reidar.vifty.daemon.plist + ! launchctl print system/tech.reidar.vifty.daemon >/dev/null 2>&1 + + If any authority is active or unknown, stop. Do not move the app, retry the lifecycle, or reset BTM. If the lifecycle reports HELPER_UNREACHABLE, continue only when the exact reviewed public v1.4.5 offline recovery path and fresh Auto evidence are present in its record. + +- [ ] Step 4: Quarantine the old app reversibly, without deleting or copying it. + + QUARANTINE_APP="$EVIDENCE_DIR/previous-install/Vifty.app" + test ! -e "$QUARANTINE_APP" + mv /Applications/Vifty.app "$QUARANTINE_APP" + test ! -e /Applications/Vifty.app + test -d "$QUARANTINE_APP" + ruby scripts/release-candidate-inventory.rb public-tree-sha256 \ + --app "$QUARANTINE_APP" \ + >"$EVIDENCE_DIR/quarantined-app-content-manifest.txt" + cmp -s \ + "$EVIDENCE_DIR/previous-app-content-manifest.txt" \ + "$EVIDENCE_DIR/quarantined-app-content-manifest.txt" + + The mv is a same-volume, reversible quarantine of the exact prior bundle, not deletion or a second install. If permissions prevent this move, stop and ask the owner to perform the same exact-path move in Finder; never use sudo mv from the agent. Preserve the quarantine until final review. + +- [ ] Step 5: Check duplicate BTM registrations without resetting them. + + sfltool dumpbtm >"$EVIDENCE_DIR/btm-after-quarantine.txt" 2>&1 + find /Applications "$HOME/Applications" -maxdepth 3 -type d -name Vifty.app -print 2>/dev/null | sort >"$EVIDENCE_DIR/app-paths-after-quarantine.txt" + + Preserve /Applications/Vifty Recovery 14/Vifty.app and any historical evidence bundle. If a duplicate registration is active rather than disabled/stale, use that exact app's native unregister/quit path and capture a new readback; never call sfltool resetbtm and never delete an unfamiliar bundle. + +### Task 4: Install the exact public runtime through the reviewed transaction + +**Files:** +- Use: Makefile target install-public-release, scripts/install-vifty.sh +- Preserve: $EVIDENCE_DIR/previous-install/Vifty.app and all installer receipts +- Create: $EVIDENCE_DIR/public-install.log, $EVIDENCE_DIR/selected-app-codesign.txt, $EVIDENCE_DIR/selected-daemon-codesign.txt + +**Interfaces:** +- Consumes: absent canonical destination, no active helper authority, exact archive verification, and quarantined previous app. +- Produces: /Applications/Vifty.app containing the exact verified Developer ID archive, or a preserved stop state. + +- [ ] Step 1: Recheck the destination precondition immediately before install. + + test ! -e /Applications/Vifty.app + test ! -e /Library/PrivilegedHelperTools/tech.reidar.vifty.helper + test ! -e /Library/LaunchDaemons/tech.reidar.vifty.daemon.plist + ! launchctl print system/tech.reidar.vifty.daemon >/dev/null 2>&1 + +- [ ] Step 2: Run the exact public installer once. + + set -o pipefail + make install-public-release \ + PUBLIC_RELEASE_ARCHIVE="$PUBLIC_ARCHIVE" \ + 2>&1 | tee "$EVIDENCE_DIR/public-install.log" + + Run this immediately after the successful Task 3 quarantine in the same foreground terminal session. No build is needed here: the archive is already pinned and verified. The reviewed public transaction owns its own replacement prepare/finish lifecycle and may show one or more native administrator dialogs; type the password directly into each Vifty dialog as soon as it appears. Do not use `sudo -v`, retry after exit 75/76, replay a root phase, manually edit a ledger, copy the app into place, or invoke a workflow/tag/release action. If the installer fails, preserve the quarantined app and transaction files and stop for owner recovery. + +- [ ] Step 3: Verify the selected public bundle before helper repair. + + codesign -dvvv /Applications/Vifty.app >"$EVIDENCE_DIR/selected-app-codesign.txt" 2>&1 + codesign -dvvv /Applications/Vifty.app/Contents/MacOS/ViftyDaemon >"$EVIDENCE_DIR/selected-daemon-codesign.txt" 2>&1 + grep -Fqx 'TeamIdentifier=X88J3853S2' "$EVIDENCE_DIR/selected-app-codesign.txt" + grep -Fqx 'TeamIdentifier=X88J3853S2' "$EVIDENCE_DIR/selected-daemon-codesign.txt" + +### Task 5: Validate installer-managed helper registration; repair only on an explicit narrow exception + +**Files:** +- Use: /Applications/Vifty.app and the public install transaction's lifecycle receipts +- Conditional use only: scripts/repair-vifty-helper.sh, Makefile target repair-helper +- Read only: launchd, BTM, unified logs, helper/daemon hashes +- Create: $EVIDENCE_DIR/repair-helper.log, $EVIDENCE_DIR/launchctl-after-repair.txt, $EVIDENCE_DIR/btm-after-repair.txt, $EVIDENCE_DIR/daemon-log-after-repair.txt + +**Interfaces:** +- Consumes: the verified public bundle and the public install transaction's `finish` readback. +- Produces: proof that the installer registered one canonical daemon bound to the installed bundle. A separate repair is not part of the happy path. + +- [ ] Step 1: Capture the installer-owned registration and process readback. + + launchctl print system/tech.reidar.vifty.daemon >"$EVIDENCE_DIR/launchctl-after-repair.txt" 2>&1 + sfltool dumpbtm >"$EVIDENCE_DIR/btm-after-repair.txt" 2>&1 + /usr/bin/log show --style compact --last 15m \ + --predicate 'process == "xpcproxy" OR process == "launchd" OR eventMessage CONTAINS[c] "tech.reidar.vifty.daemon"' \ + >"$EVIDENCE_DIR/daemon-log-after-repair.txt" 2>&1 + shasum -a 256 /Applications/Vifty.app/Contents/MacOS/ViftyDaemon >"$EVIDENCE_DIR/installed-daemon-sha256.txt" + + Pass only when the daemon is not spawn failed, has no last exit code = 78: EX_CONFIG, has no needs LWCR update, and the registered executable path resolves to /Applications/Vifty.app/Contents/MacOS/ViftyDaemon. The public install's successful finish receipt and these readbacks are the normal helper-repair proof. + +- [ ] Step 2: Use the repair target only for the narrow post-install exception. + + Invoke the following exactly once only if the public installer exited `0`, helper authority is inactive rather than active/unknown, and the installer/readback evidence specifically says SMAppService registration did not reach enabled state: + + set -o pipefail + REPAIR_HELPER_APP=/Applications/Vifty.app \ + make repair-helper 2>&1 | tee "$EVIDENCE_DIR/repair-helper.log" + + Approve the native administrator prompt immediately if it appears. If the installer exited 75/76, if authority is active/unknown, or if the failure is a duplicate/stale BTM registration, do not run this exception; preserve evidence and stop. + +- [ ] Step 3: Stop on any registration ambiguity. + + If launchd shows a duplicate path, stale BTM authority, unknown active state, Team ID mismatch, or repeated crash, stop. Capture the readback and ask for one owner-authorized restart only if the app/lifecycle explicitly requires it; do not reset BTM or retry registration in a loop. + +### Task 6: Prove daemon-backed Auto and readiness before any manual mode + +**Files:** +- Use: /Applications/Vifty.app/Contents/MacOS/viftyctl diagnose, status, capabilities +- Use UI only for one Auto restoration if the app reports an active manual session +- Create: $EVIDENCE_DIR/diagnose-after-repair.json, $EVIDENCE_DIR/status-after-repair.json, $EVIDENCE_DIR/capabilities-after-repair.json, $EVIDENCE_DIR/auto-proof.json + +**Interfaces:** +- Consumes: a responding public daemon and matching helper. +- Produces: diagnose --json exit 0, safeToRequestCooling=true, daemonControlPathReady=true, manualControlActive=false, empty cooling blockers, and fresh Auto/System ownership evidence. + +- [ ] Step 1: Run the read-only readiness commands. + + /Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json \ + >"$EVIDENCE_DIR/diagnose-after-repair.json" \ + 2>"$EVIDENCE_DIR/diagnose-after-repair.stderr" + printf '%s\n' "$?" >"$EVIDENCE_DIR/diagnose-after-repair.exit" + /Applications/Vifty.app/Contents/MacOS/viftyctl status --json >"$EVIDENCE_DIR/status-after-repair.json" + /Applications/Vifty.app/Contents/MacOS/viftyctl capabilities --json >"$EVIDENCE_DIR/capabilities-after-repair.json" + + Require exit 0 and the structured fields above. Do not infer readiness from a successful process launch or a catalog listing. + +- [ ] Step 2: Clear a manual marker only through the normal owner path. + + If manualControlActive is true, use the Vifty UI's Restore Auto action once, wait for a fresh daemon snapshot, and rerun diagnose --json. Do not call ViftyHelper auto or loop the restore command. If the marker persists, inspect the saved startup mode and ask the owner to select Auto; stop before manual smoke. + +- [ ] Step 3: Save the Auto proof. + + Record the exact daemon snapshot/audit event, observed fan hardware modes, target telemetry, marker status, and timestamp in $EVIDENCE_DIR/auto-proof.json. The proof must show fresh OS/System ownership for every expected fan, not merely a local preference value. + +### Task 7: Perform supervised Fixed/Curve/Auto acceptance only when readiness is green + +**Files:** +- Use: /Applications/Vifty.app UI only +- Read only: daemon snapshots, receipts, audit events, thermal pressure, process state +- Create: $EVIDENCE_DIR/manual-smoke/ with one record per mode and a final Auto record + +**Interfaces:** +- Consumes: Task 6 readiness and fresh fan IDs/ranges from the daemon. +- Produces: receipt-backed Fixed and Curve acceptance, followed by explicit Auto restoration, or a precise hardware non-claim. + +- [ ] Step 1: Freeze the valid test inputs from fresh telemetry. + + Read fan IDs and ranges from the current daemon snapshot. Select conservative in-range values through the UI; do not invent IDs, write raw RPMs, or use a stale snapshot. Record the selected profile and values before applying. + +- [ ] Step 2: Apply Fixed through the UI and validate the receipt. + + Use the UI's Fixed mode. Require a fresh transaction receipt, daemon readback for every selected fan, matching ownership, accepted/clamped target, and a corresponding audit event. A successful button return without readback is not acceptance. + +- [ ] Step 3: Restore Auto through the UI and validate fresh OS/System readback. + + Use the UI's Auto action once. Require all fans to report automatic/system ownership, a fresh readback timestamp, no manual marker, and a successful audit entry. If Auto restoration fails, stop and preserve the receipt; do not proceed to Curve. + +- [ ] Step 4: Apply Curve through the UI and validate the receipt. + + Use one bounded Curve profile whose points are already in the saved profile or selected through the UI. Require the same fresh receipt/readback/audit conditions as Fixed. Do not claim learner/profile quality from source tests alone. + +- [ ] Step 5: Restore Auto a final time and close the smoke session. + + Require a final Auto proof and rerun diagnose --json. If any hardware write is rejected, classify the exact receipt-backed cause and stop; do not patch around it or repeat the same workload. + + This task is inherently supervised because it exercises real fan control. The operator may go AFK only after this task is either completed with final Auto proof or explicitly skipped and recorded as unverified. + +### Task 8: Close the transaction and leave an auditable handoff + +**Files:** +- Create/update: docs/reviews/2026-09-12-vifty-one-shot-recovery.md +- Create: $EVIDENCE_DIR/final-preservation-check.txt, $EVIDENCE_DIR/manifest.tsv, $EVIDENCE_DIR/review-summary.json +- Preserve: quarantined previous app and all root replacement receipts + +**Interfaces:** +- Consumes: all phase evidence and final runtime state. +- Produces: a truthful completion report with verified claims and explicit non-claims. + +- [ ] Step 1: Recheck source and workspace preservation. + + git diff --check + git status --short -b + df -h /System/Volumes/Data + find /private/tmp -maxdepth 1 -type d \( -name 'Vifty*' -o -name 'vifty*' \) -print -exec du -sh {} \; 2>/dev/null || true + + Do not clean .build, prior evidence, the quarantined app, credentials, wallets, or unrelated temporary paths. + +- [ ] Step 2: Verify final runtime identity read-only. + + codesign -dvvv /Applications/Vifty.app >"$EVIDENCE_DIR/final-app-codesign.txt" 2>&1 + launchctl print system/tech.reidar.vifty.daemon >"$EVIDENCE_DIR/final-launchctl.txt" 2>&1 + sfltool dumpbtm >"$EVIDENCE_DIR/final-btm.txt" 2>&1 + /Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json >"$EVIDENCE_DIR/final-diagnose.json" 2>"$EVIDENCE_DIR/final-diagnose.stderr" + printf '%s\n' "$?" >"$EVIDENCE_DIR/final-diagnose.exit" + +- [ ] Step 3: Write the final review and Obsidian log. + + The review must distinguish source verification, public archive verification, installation, helper/daemon registration, Auto proof, Fixed/Curve receipts, and hardware acceptance. It must not transfer proof from the ad-hoc predecessor, prior releases, source tests, or catalog metadata into the installed public-runtime claim. + +## Stop-condition matrix + +| Failure | Immediate action | Forbidden response | +|---|---|---| +| op signin fails | Stop before source/system work; ask the owner to enable desktop integration or authorize the prompt | Read vault secrets, paste a secret key, log a session token | +| Free space below 30 GiB | Stop and report | Start builds or cleanup unrelated data | +| HEAD/tree differs from the bound run | Stop and bind a new plan/run | Silently continue on a new dirty tree | +| Source gate fails | Preserve test log and debug source-only | Install, repair helper, or modify hardware state | +| Archive SHA/signing/Gatekeeper fails | Stop before installation | Accept a different archive or skip checks | +| Existing helper authority active/unknown | Stop before quarantine | Reset BTM, raw launchctl, replay lifecycle phases | +| Helper uninstall cancellation | Preserve receipt and exact state | Retry blindly or remove files manually | +| Duplicate active BTM registration | Stop and use the native exact-app unregister path | sfltool resetbtm | +| Public installer exits 75/76 | Preserve transaction and quarantine | Retry, edit ledger, copy bundle manually | +| Diagnose remains blocked | Stop before fan writes | prepare, ViftyHelper, or uncooled fallback | +| Fixed/Curve receipt/readback fails | Restore Auto once if safe, preserve evidence, stop | Repeat the same write or patch speculatively | +| Auto restoration fails | Keep hardware claim unverified and stop | Proceed to the next mode | + +## AFK choreography + +1. The first command is op signin; the only expected immediate user action is the 1Password desktop authorization. +2. The long source/archive gate then runs without system mutation and without 1Password-backed Git signing. +3. Before the helper transition, the operator must be present for the native administrator prompt. The lifecycle receipt is short-lived, so it cannot be authorized and left waiting for a long build. +4. Helper teardown and public install run as one prepared foreground transition. macOS may show multiple administrator/Login Items prompts across the reviewed lifecycle's prepare/finish boundaries; approve only the Vifty prompt and remain until the prompt burst ends. The happy path does not run a redundant post-install repair. +5. After Task 5/6 has produced healthy Auto proof, the non-hardware portion can run AFK. Fixed/Curve acceptance remains supervised by design. + +## Definition of done + +- Exact v1.4.5 archive SHA, Developer ID, Team ID, notarization, stapling, Gatekeeper, and content binding pass. +- /Applications/Vifty.app is the selected canonical signed bundle; the quarantined ad-hoc predecessor remains preserved and recorded. +- The helper and daemon are registered once, launchd has no LWCR/EX_CONFIG failure, the installed daemon hash matches the selected bundle, and BTM has no active duplicate ambiguity. +- diagnose --json exits 0 with safeToRequestCooling=true, daemonControlPathReady=true, manualControlActive=false, and no blockers. +- Auto proof is fresh and receipt-backed. Fixed/Curve are claimed only if their supervised receipts, daemon readbacks, audit entries, and final Auto restoration pass. +- Evidence, review, and Obsidian logging are complete; the dirty branch is preserved and no release/merge/deploy occurred. diff --git a/docs/superpowers/plans/2026-09-12-vifty-repair-root-cause-plan.md b/docs/superpowers/plans/2026-09-12-vifty-repair-root-cause-plan.md new file mode 100644 index 00000000..db8724b5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-vifty-repair-root-cause-plan.md @@ -0,0 +1,342 @@ +# Vifty runtime recovery and Fixed/Curve acceptance plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to execute this plan task-by-task with review checkpoints. + +**Goal:** Repair the Vifty installation path and validate the current dirty source on live hardware so Fixed RPM and Temperature Curve either work with confirmed SMC readback or stop with a precise, preserved blocker. + +**Architecture:** Keep the existing fail-closed lifecycle and fan-control transaction. Make the smallest orchestration correction needed to separate the canonical replacement target from the signed control bundle used to recover an unreachable helper. Then build the current source as a local Developer ID app, register its matching daemon through the existing macOS service flow, and perform bounded UI-driven Fixed/Curve smoke tests with Auto restoration and evidence. + +**Tech Stack:** Swift 6 / Swift Package Manager, XCTest, Bash, Ruby contract fixtures, macOS `SMAppService`, launchd, IOKit/SMC, Developer ID signing. + +**Spec / handoff:** `/Users/reidar/.codex/attachments/52d8f77d-cf59-4c09-b083-ad135b6a23ce/pasted-text.txt` + +**Current review:** `/Users/reidar/Projectos/Vifty/docs/reviews/2026-09-12-vifty-root-cause-review.md` + +## Current execution override — 12-09-2026 + +The original recovery-source separation work in this plan is complete and the daemon lifetime root cause is now fixed. Do not repeat those phases or install the historical public archive. + +### Verified now + +- `Sources/ViftyDaemon/main.swift` uses `dispatchMain()` after Mach-listener registration; `Tests/ViftyCoreTests/DaemonTerminationSignalGateTests.swift` guards against regression. +- Hermetic `make verify-full SWIFT_BUILD_PATH="$PWD/.build"` passed 2,041 XCTest cases and all release/Ruby trust suites. The current dirty source was then rebuilt as a local Developer ID candidate and deep-strict signature verification passed. +- The candidate daemon SHA is `1202d284d60e930e871fe5df1fc6ef133a3eed6106f9811883a6e39f0bf2bd2e`. The installed `/Applications/Vifty.app` is Developer ID signed but still contains the older daemon SHA `6802e5b1966fac2c006244ade309526dbf3dabe538cdca1344513ce46fdef751`. +- Read-only launchd/BTM evidence points at the canonical `/Applications` bundle, while that old daemon exits 0 before serving XPC. Installed `viftyctl diagnose --json` therefore exits 75 with `HELPER_UNREACHABLE`; direct local telemetry confirms both physical fans are in Auto/System mode. No fan/SMC write has been attempted. + +### The only remaining critical path + +1. Capture fresh read-only state and keep the signed candidate immutable. +2. Obtain one explicit owner-present authorization for termination of the currently running old Vifty app. The reviewed installer will try normal AppleScript quit first; it must not force-terminate or overwrite a live bundle. If normal quit still cannot prove safe termination, the owner must perform the one-time native Force Quit/Activity Monitor action; Codex must not do that silently. +3. Run the reviewed same-path installer against `/Applications/Vifty.app`. Do not set `QUIT_RUNNING_APP=0` as a bypass; that mode intentionally exits 75. Do not reset BTM, mutate launchd directly, or reboot as a first-line fix. +4. Re-read installed app/daemon byte identity, `launchctl print`, BTM, and `viftyctl diagnose --json`. Continue only when the new daemon remains alive and diagnose exits 0 with daemon and policy status available. +5. If the new bundle is installed but `SMAppService` still reports `enabled` while launchd resolves the old daemon bytes, stop. Add a minimal identity-aware stale-registration check/rebind path through the existing native service-management flow; never solve that branch with raw launchctl/BTM edits. +6. Only after readiness passes, perform supervised UI Fixed and Curve tests with fresh daemon readback and explicit Auto restoration. Keep all live hardware claims separate from source/build evidence. + +This override supersedes the earlier “ad-hoc app/public archive” starting-state text and the already-completed recovery-source implementation tasks below; the architectural safety requirements and later readiness/acceptance gates remain applicable. + +## Non-negotiable boundaries + +- Preserve all existing dirty worktree changes and recovery artifacts. Never use `git reset`, `git checkout`, `git clean`, `git stash`, broad deletion, or an unreviewed overwrite. +- Do not run `sudo`, `sfltool resetbtm`, raw SMC tools, `ViftyHelper setFixed`, `ViftyHelper auto`, direct fan RPM writes, unguarded `viftyctl prepare`, or hand-written replacement lifecycle phases. +- Do not ask for or place an administrator password, 1Password secret, or fingerprint in chat. The native macOS prompt is the only credential boundary. +- Do not attempt another privileged operation while the owner is AFK. Pause before the first `osascript ... with administrator privileges` call, Login Items approval, or reboot. +- Do not treat a passing source gate, catalog advertisement, bundle signature, `diagnose` exit alone, or mode registration as hardware acceptance. +- Keep the public v1.4.5 archive as recovery evidence only. Do not install it as the final current-source repair because its helper predates the dirty-tree SMC hardening. +- Use no subagents for the privileged path. The state is serialized across one root ledger, one launchd label, one BTM registration, and one physical fan transaction; parallel work would increase risk without reducing the critical path. + +## Verified starting point + +The current worktree is `codex/lean-polish` at `e79bcab20ad7c9eda302fe4594bfa547b15f929a`, with pre-existing dirty changes plus the daemon lifetime repair. The fresh hermetic gate passed under `umask 022`, including 2,041 XCTest cases, but that only proves the current source tree and fixtures. The existing `/Applications/Vifty.app` is Developer ID signed, but its installed daemon is the older pre-`dispatchMain()` binary and exits 0; the helper is unreachable, `diagnose` is blocked, the signed Recovery 14 app remains a separate usable control source, and the recovery evidence is preserved. + +The current source already contains the relevant writer hardening: + +- `LocalFanHelperClient` confirms the mode key reads back as Forced after every manual-mode write. +- A silently ignored/protected mode write may enter the existing guarded `Ftst` unlock/retry path. +- Fixed target, final Forced mode, exact target RPM, and `Ftst=0` remain receipt requirements. +- Failure cleanup still attempts Auto and reports unconfirmed recovery instead of claiming success. + +No further SMC code should be changed until the current source is running through a matching daemon and the live readback failure is reproduced or cleared. + +## Implementation tasks + +### 1. Add the smallest safe recovery-source/target separation + +**Modify:** + +- `scripts/vifty-helper-lifecycle.sh` +- `Sources/Vifty/DaemonInstallService.swift` +- `Tests/ViftyCoreTests/HelperLifecycleScriptTests.swift` +- `Tests/Ruby/InstallerLifecycleTrustContractTests.rb` +- `Tests/Ruby/HelperLifecycleReplacementFixtureTests.rb` + +The current script uses `APP_PATH` for both the executable source (`Vifty`, `viftyctl`, `ViftyHelper`, `ViftyDaemon`) and the bundle whose replacement ledger may need unlocking. That is the root cause of the failed recovery attempt. Keep `--app` as the target and add one explicit `--control-app` argument for the exceptional uninstall-only recovery path. + +Required behavior: + +1. Default `CONTROL_APP_PATH` to `APP_PATH`, so every existing normal repair/uninstall/replacement invocation is byte-for-byte equivalent in behavior. +2. Accept `--control-app` only when `--operation uninstall` is selected and no replacement phase is selected. Reject it for repair and for `prepare`, `finish`, or `release-lock` with a usage/configuration error before invoking any helper, `launchctl`, or root worker. +3. Canonicalize both paths without following a symlink for the final bundle. Require both to be real `Vifty.app` directories, require the control path to differ from the target, and record the target in the existing `app` field. Add an explicit control-source field to the operator record so the recovery provenance is not ambiguous. +4. Resolve `VIFTY_CTL`, `VIFTY_MAIN`, `VIFTY_HELPER`, and `VIFTY_DAEMON` from `CONTROL_APP_PATH`. Keep all replacement-ledger lookup, `capture_bundle_binding`, `release_prior_replacement_lock_after_quiesce`, and target identity checks bound to `APP_PATH`. +5. Validate the control app before any maintenance command: complete bundle, exact component identifiers, deep strict code signature, Developer ID signatures for all four executables, one matching TeamID `X88J3853S2`, and the pinned recovery helper SHA `4c467d99f7e59c2727f0e1a9b13de81772741d269b560ce6ca9fb605782f0d0f`. Reuse the existing bundle-binding/signature checks where possible; do not add a new signing verifier. +6. Snapshot the control app’s helper as the trusted offline helper. Continue to use the existing root verification, Auto-only authorization, launchd disable/offline proof, caller binding, and root-owned ledger removal. The root worker must never execute the control app’s `Vifty` or `viftyctl`; those remain user-side control calls, while the root worker uses only its staged helper snapshot. +7. Keep the ordinary public fallback restricted to uninstall. Do not make `--control-app` a general repair bypass or allow it to authorize a raw fan write. +8. Update the lifecycle hash embedded in `DaemonLifecycleScriptLoader.bundled` after the script change. Compute it with `/usr/bin/shasum -a 256 scripts/vifty-helper-lifecycle.sh` and replace the compiled literal with that exact digest; add/retain a test that the bundled loader accepts only those bytes. + +Tests to add or extend: + +- A fixture uninstall with a locked replacement ledger bound to target app A and signed/control fixture app B. Assert that control calls use B, the root record/ledger uses A, A is unlocked only after Auto proof, the transaction evidence is removed only after identity validation, and the service is left absent. +- A negative test that passes `--control-app` to repair or a replacement phase and proves no helper or registrar is invoked. +- A negative test for an ad-hoc, wrong-TeamID, wrong-identifier, symlinked, or wrong-helper-digest control bundle; assert a fail-closed result and an unchanged target ledger/tree. +- A regression assertion that using the Recovery 14 bundle as the control source no longer causes the `/Applications` replacement ledger to be treated as unrelated. +- Contract assertions for the new argument, target/control fields, the pinned helper digest, and the absence of direct `chflags`, `sudo`, raw replacement-phase replay, or unguarded SMC commands in the operator path. + +Do not add a second recovery script, a new root protocol, or a `--force-unlock` flag. The existing lifecycle already owns the hard part. + +### 2. Run focused verification before touching the machine again + +Run from `/Users/reidar/Projectos/Vifty`: + +```sh +df -h /System/Volumes/Data +git diff --check +/bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh +swift test --filter ViftyCoreTests.HelperLifecycleScriptTests +swift test --filter ViftyCoreTests.DaemonInstallServiceTests +swift test --filter ViftyCoreTests.LocalFanHelperClientTests +/usr/bin/ruby Tests/Ruby/InstallerLifecycleTrustContractTests.rb +/usr/bin/ruby Tests/Ruby/HelperLifecycleReplacementFixtureTests.rb +``` + +The long gate must use a permissive fixture umask and isolated Git configuration so a restrictive shell umask cannot create false fixture failures: + +```sh +( + umask 022 + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + make verify-full SWIFT_BUILD_PATH="$PWD/.build" +) +``` + +Capture every result under the bounded evidence directory `/Users/reidar/Projectos/Vifty/.build/vifty-recovery/root-cause-20260912/`. Do not create unbounded `/private/tmp` scratch trees. If free disk falls below 30 GiB, stop before another build. Preserve the existing evidence directory and prior logs. + +### 3. Build a current, locally signed candidate without calling it a release + +A Developer ID identity is currently available for TeamID `X88J3853S2`. After the focused/full source gates pass, build the dirty source with that identity and the release XPC TeamID: + +```sh +SIGNING_IDENTITY='Developer ID Application: REIDAR OVERREIN JOESSUND (X88J3853S2)' \ +VIFTY_XPC_ALLOWED_TEAM_ID='X88J3853S2' \ +make app CONFIGURATION=release SWIFT_BUILD_PATH="$PWD/.build" +``` + +Verify the candidate before installation: + +- `codesign --verify --deep --strict .build/Vifty.app` succeeds. +- All four bundled executables have the expected identifiers and TeamID. +- The bundled LaunchDaemon plist points at the current candidate and has `VIFTY_XPC_ALLOWED_TEAM_ID=X88J3853S2`. +- The embedded lifecycle digest equals the current script digest. +- The candidate’s source provenance is recorded as the dirty `codex/lean-polish` worktree at HEAD `e79bcab...`, not as a public release. + +Do not update `Casks/vifty.rb`, the release manifest, GitHub, or public release notes. This is a local runtime validation build. + +### 4. Wait for the owner-authorized recovery boundary + +Do not execute this phase now while the owner is AFK. When the owner is present, explain that one native administrator prompt is about to appear and that the password/fingerprint must be entered only into macOS’s prompt. Do not collect it in chat. + +Before the prompt, capture read-only state: + +- current `/Applications/Vifty.app` path, bundle version/build, flags, component hashes, and signature summary; +- root ledger and transaction metadata hashes/ownership without reading protected secret material; +- `launchctl print`/`print-disabled` for `tech.reidar.vifty.daemon`; +- BTM records for both `/Applications/Vifty.app` and the Recovery 14 source; +- process and `lsof` checks proving no Vifty app/daemon/helper is actively mutating the target; +- `viftyctl diagnose --json` exit/status, expected to remain blocked at this point. + +1Password is not a gate here: the earlier `op signin`/`op whoami` check already completed and no secret was accessed. + +### 5. Execute the corrected single recovery operation + +Use the reviewed lifecycle once, with the canonical target and signed control source separated: + +```sh +./scripts/uninstall-vifty.sh \ + --app /Applications/Vifty.app \ + --control-app "$HOME/Applications/Vifty Recovery 14/Vifty.app" \ + --record "/Users/reidar/Projectos/Vifty/.build/vifty-recovery/root-cause-20260912/uninstall-control-target.json" +``` + +Expected lifecycle properties: + +- the control app’s `viftyctl` classifies the unreachable helper and the control app’s `Vifty` performs the verified post-root legacy unregister; +- the root worker disables and proves the exact daemon label offline; +- the pinned public Auto-only helper is staged only after root verification; +- the helper proves complete current fan inventory, fresh Auto/System readback, and `Ftst=0` before cleanup; +- the root worker validates the existing `/Applications` replacement ledger against `/Applications/Vifty.app`, unlocks only that exact target and its transaction child, and removes the ledger only through the existing durable identity checks; +- legacy helper/daemon/plist/log artifacts are removed only after the same proof; +- no fan-write operation is issued and no direct `chflags`/`sudo` path is used. + +Accept exit 0 only with the lifecycle record and root evidence showing all required phases. Exit 75/76 is a stop, not a reason to retry. Preserve the evidence and report the exact failed phase. + +Immediately verify, read-only: + +```sh +stat -f '%N %Su:%Sg %Sf' /Applications/Vifty.app +launchctl print system/tech.reidar.vifty.daemon +launchctl print-disabled system +``` + +The target bundle may still exist, but it must no longer carry the replacement `schg` lock, and the helper label must be absent/disabled according to the lifecycle record. + +### 6. Reversibly quarantine the obsolete ad-hoc app + +Only after the corrected lifecycle proves the target is unlocked, the label is offline, and no process has an open handle: + +1. Create a unique, private evidence/quarantine directory inside the repository’s bounded recovery evidence tree. +2. Move `/Applications/Vifty.app` there with one reversible filesystem move. +3. Verify the move by path, content manifest, ownership, and absence of `/Applications/Vifty.app`. +4. Record the quarantine path; do not delete it. + +If the move is denied after the lifecycle’s unlock proof, stop and report the exact flag/ownership/handle evidence. Do not run `chflags`, `sudo mv`, Finder workarounds, or repeated attempts. + +The Recovery 14 source app remains preserved in `~/Applications` unless the BTM cleanup requires an owner-approved normal uninstall/reboot. Do not delete it as part of this step. + +### 7. Clear the BTM/LWCR conflict through native state only + +Re-read Login Items/BTM and launchd after the lifecycle. The desired state is one canonical future registration source: `/Applications/Vifty.app`; no active Recovery 14 duplicate; no stale root `/Applications` registration; no `LWCR update`/exit 78 launch failure. + +- If exact lifecycle unregister removed the duplicate, continue. +- If macOS still reports an enabled duplicate or stale `LWCR`, stop and request one owner-present reboot. Do not reset BTM with `sfltool`, edit its database, or repeatedly register/unregister from competing bundles. +- After reboot, re-check BTM, launchd, and the preserved evidence before installation. A reboot is an explicit owner action and cannot be completed unattended. + +### 8. Install the current dirty source to the canonical path + +Once `/Applications` is absent and BTM is not actively owning a conflicting Vifty service, install the candidate built from the current source: + +```sh +SIGNING_IDENTITY='Developer ID Application: REIDAR OVERREIN JOESSUND (X88J3853S2)' \ +VIFTY_XPC_ALLOWED_TEAM_ID='X88J3853S2' \ +OPEN_AFTER_INSTALL=0 \ +make install SWIFT_BUILD_PATH="$PWD/.build" +``` + +The installer’s existing preflight and replacement transaction must remain enabled. It should see an absent destination, build/copy the current candidate, and verify source/staged/installed byte identity and Developer ID signatures. If it instead sees a conflicting app or helper state, stop; do not weaken the preflight or fall back to the old public archive. + +Record the installed app’s current dirty-tree provenance, version/build, component hashes, plist TeamID, and `codesign` output. This is a local Developer ID build, not a published release and not evidence transferable to `v1.4.5` public-release claims. + +### 9. Register the matching daemon and resolve approval once + +Open the exact installed app: + +```sh +open /Applications/Vifty.app +``` + +Use the existing app UI’s Install/Reinstall Helper action. If macOS opens Login Items approval, the owner must approve it while present. Do not automate or bypass that UI. Do not run raw launchctl registration. + +If the app reports `requiresApproval`, approve once and wait for the app to refresh. If it reports `notRegistered`, register from the app. If it reports `unknown`, stop and reboot rather than guessing. If a registered but mismatched daemon remains, use the app’s Repair/Reinstall Helper action, which invokes the reviewed lifecycle and its native administrator prompt. + +Verify that the installed daemon path and SHA match `/Applications/Vifty.app/Contents/MacOS/ViftyDaemon`, that the label is loaded from the canonical bundle, that TeamID/signing constraints are satisfied, and that no `OS_REASON_CODESIGNING`, `LWCR`, or exit 78 remains. + +### 10. Prove readiness before any fan command + +Run only read-only checks first: + +```sh +/Applications/Vifty.app/Contents/MacOS/viftyctl diagnose --json \ + > "/Users/reidar/Projectos/Vifty/.build/vifty-recovery/root-cause-20260912/diagnose-ready.json" +diagnose_status=$? +printf '%s\n' "$diagnose_status" > "/Users/reidar/Projectos/Vifty/.build/vifty-recovery/root-cause-20260912/diagnose-ready.exit" + +VIFTYCTL=/Applications/Vifty.app/Contents/MacOS/viftyctl \ +MANUAL_SMOKE_READINESS_JSON=1 \ +MANUAL_SMOKE_EXPECTED_DAEMON=/Applications/Vifty.app/Contents/MacOS/ViftyDaemon \ +MANUAL_SMOKE_REQUIRE_DAEMON_MATCH=1 \ +make manual-smoke-readiness +``` + +Proceed to hardware smoke only when all of the following are true: + +- `diagnose` exits 0; +- daemon status and policy status are available; +- `safeToRequestCooling` and the manual control readiness gate are true; +- `manualControlActive` is false and ownership/recovery state is clear; +- the complete fan inventory is present with valid mode keys, ranges, and current Auto/System readback; +- the installed daemon hash matches the current installed app; +- no BTM/LWCR or helper-registration blocker remains. + +If any item fails, preserve JSON/stderr and stop. Do not test Fixed/Curve against a blocked daemon. + +### 11. Perform bounded live Fixed RPM acceptance + +Use the installed Vifty UI only. Select a safe target derived from each fan’s live minimum/maximum range; do not hard-code a low RPM and do not use `ViftyHelper` or raw SMC tools. + +1. Capture the pre-test snapshot: fan IDs, mode keys, current Auto/System mode, target/min/max RPM, thermal pressure, and helper/daemon identity. +2. Select Fixed RPM in the UI and apply one bounded target for the shortest meaningful observation window. +3. Capture the UI/daemon receipt and a fresh snapshot for every selected fan. +4. Accept only if each fan has confirmed Forced mode, exact requested target RPM, `Ftst=0`, no mutation/recovery errors, and a matching daemon transaction receipt. +5. Select Auto in the UI and wait for a fresh receipt proving Auto/System-managed mode for the complete fan set and `Ftst=0`. + +A mode label alone is not acceptance. A write return alone is not acceptance. If readback stays Auto or target drift persists, stop with the receipt and restore result; do not retry the same workload or bypass the daemon. + +### 12. Perform bounded Temperature Curve acceptance + +Only after Fixed passes and Auto restoration is proven: + +1. Capture the curve/profile and the selected temperature sensor identity. +2. Select Temperature Curve in the UI with the existing normalized three-point profile. +3. Use a short, bounded workload or natural thermal change sufficient to produce a fresh sensor sample; avoid an unbounded stress process. +4. Verify the coordinator’s resolved RPM is within the fan’s live bounds and that the daemon receipt/readback reports Forced mode and the expected target for the curve sample. Capture at least one second sample if the temperature changes. +5. Select Auto and require complete Auto/System plus `Ftst=0` readback before ending. + +If the sensor is missing, curve resolution is malformed, the target is outside bounds, or the daemon receipt does not match the displayed curve decision, stop and preserve the exact state. Do not classify a UI selection as a working curve. + +### 13. Optional agent-cooling smoke only after manual acceptance + +This is not required to prove Fixed/Curve. If the user later wants it, run the existing read-only readiness gate and supervised guarded-run collector only after manual acceptance, with a short duration and conservative maximum RPM. Never call `viftyctl prepare` directly. Keep the agent result separate from manual hardware acceptance. + +### 14. Final verification and handoff + +Run the final read-only inventory: + +- `git status --short --branch`, `git diff --check`, and exact current source SHA/dirty state; +- installed app version/build, code signatures, TeamID, daemon plist, installed daemon SHA, and launchd path; +- BTM/Login Items records with one canonical enabled registration; +- `viftyctl diagnose --json`, status, audit, and readiness output; +- Fixed receipt/readback and Auto receipt/readback; +- Curve receipt/readback and Auto receipt/readback; +- preserved quarantine path and all recovery evidence paths; +- disk free space and owned temporary-directory check. + +Only then may the final report say “Fixed and Curve passed on this runtime.” If the live gates did not run or any gate failed, report “source repaired / runtime not accepted” with the exact blocker and no stronger claim. + +## Definition of done + +The repair is complete only when all rows are true: + +| Gate | Required proof | +| --- | --- | +| Source integrity | Focused tests and hermetic `make verify-full` pass on the preserved dirty tree. | +| Recovery correctness | The new target/control lifecycle test passes and the current run unlocks only the exact `/Applications` ledger-bound tree. | +| Old-state preservation | The old ad-hoc app is reversibly quarantined with a content manifest; no recovery evidence is deleted. | +| macOS registration | One canonical Developer ID app owns the daemon; no duplicate BTM/LWCR failure remains. | +| Runtime parity | Installed daemon bytes and app-bundled daemon bytes match exactly. | +| Readiness | `diagnose` exit 0, complete fan inventory, policy/daemon availability, no blockers, manual control inactive. | +| Fixed | Every selected fan confirms Forced and exact target readback, with `Ftst=0`, followed by confirmed Auto. | +| Curve | Curve decision/resolved RPM and daemon receipt/readback agree, followed by confirmed Auto. | +| Evidence | Logs/JSON/receipts are bounded, privacy-safe, and tied to the installed dirty-source provenance. | + +## Stop conditions + +Stop immediately and preserve the evidence on any of these conditions: + +- lifecycle exit 75 or 76; +- replacement ledger, transaction path, bundle identity, code signature, or control-source digest mismatch; +- helper label active/unknown after a supposed freeze; +- BTM duplicate, `LWCR`, or exit 78 registration failure after exact unregister; +- any administrator/Login Items/reboot prompt while the owner is unavailable; +- `diagnose` blocked, incomplete, or missing daemon/policy status; +- Fixed/Curve readback mismatch, target drift, missing Auto confirmation, or `Ftst` not zero; +- free disk below 30 GiB or unexplained large temporary directories; +- any temptation to bypass a gate with raw `sudo`, `chflags`, SMC/helper commands, `sfltool`, or a second retry. + +The next action after a stop is a small evidence review, not another blind attempt. diff --git a/scripts/check-agent-run-smoke-readiness.sh b/scripts/check-agent-run-smoke-readiness.sh index 8112e1d1..095486f3 100755 --- a/scripts/check-agent-run-smoke-readiness.sh +++ b/scripts/check-agent-run-smoke-readiness.sh @@ -27,9 +27,11 @@ This script only runs: viftyctl capabilities --json viftyctl diagnose --json -It may also hash the installed LaunchDaemon helper and --expected-daemon when -provided. It does not call prepare, run, restore-auto, ViftyHelper, sudo, or raw -SMC tools. Exit 0 means the supervised agent-run smoke collector may proceed. +It hashes the installed daemon path reported by diagnose when available, +falling back to the legacy LaunchDaemon helper path, and hashes +--expected-daemon when provided. It does not call prepare, run, restore-auto, +ViftyHelper, sudo, or raw SMC tools. Exit 0 means the supervised agent-run +smoke collector may proceed. Exit 75 means the smoke collector must be skipped until the printed blockers are cleared. JSON output uses schemaID: https://vifty.local/schemas/agent-run-smoke-readiness.schema.json @@ -42,7 +44,7 @@ MAX_RPM_PERCENT="${VIFTY_AGENT_RUN_SMOKE_MAX_RPM_PERCENT:-55}" REASON="${VIFTY_AGENT_RUN_SMOKE_REASON:-agent run smoke test}" EXPECTED_DAEMON_PATH="${VIFTY_AGENT_RUN_SMOKE_EXPECTED_DAEMON:-}" REQUIRE_DAEMON_MATCH="${VIFTY_AGENT_RUN_SMOKE_REQUIRE_DAEMON_MATCH:-0}" -INSTALLED_DAEMON_PATH="${VIFTY_AGENT_RUN_SMOKE_INSTALLED_DAEMON_PATH:-/Library/PrivilegedHelperTools/tech.reidar.vifty.daemon}" +INSTALLED_DAEMON_PATH="${VIFTY_AGENT_RUN_SMOKE_INSTALLED_DAEMON_PATH:-}" JSON_OUTPUT=0 SUMMARY_PATH="${VIFTY_AGENT_RUN_SMOKE_READINESS_SUMMARY:-}" @@ -164,25 +166,6 @@ if [[ ! -x "${VIFTYCTL}" ]]; then exit 69 fi -INSTALLED_DAEMON_PRESENT="false" -INSTALLED_DAEMON_SHA256="" -EXPECTED_DAEMON_SHA256="" -DAEMON_MATCHES_EXPECTED="unknown" - -if [[ -f "${INSTALLED_DAEMON_PATH}" ]]; then - INSTALLED_DAEMON_PRESENT="true" - INSTALLED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${INSTALLED_DAEMON_PATH}" | awk '{print $1}')" -fi - -if [[ -n "${EXPECTED_DAEMON_PATH}" ]]; then - EXPECTED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${EXPECTED_DAEMON_PATH}" | awk '{print $1}')" - if [[ -n "${INSTALLED_DAEMON_SHA256}" && "${INSTALLED_DAEMON_SHA256}" == "${EXPECTED_DAEMON_SHA256}" ]]; then - DAEMON_MATCHES_EXPECTED="true" - else - DAEMON_MATCHES_EXPECTED="false" - fi -fi - CAPABILITIES_JSON="$(mktemp "${TMPDIR:-/tmp}/vifty-agent-run-readiness-capabilities.XXXXXXXX.json")" CAPABILITIES_STDERR="$(mktemp "${TMPDIR:-/tmp}/vifty-agent-run-readiness-capabilities.XXXXXXXX.stderr")" DIAGNOSE_JSON="$(mktemp "${TMPDIR:-/tmp}/vifty-agent-run-readiness-diagnose.XXXXXXXX.json")" @@ -205,6 +188,39 @@ fi DIAGNOSE_STATUS=$? set -e +if [[ -z "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PATH="$(/usr/bin/ruby -rjson -e ' + begin + payload = JSON.parse(File.read(ARGV.fetch(0))) + path = payload.dig("daemonRuntime", "installedDaemonPath") + puts path if path.is_a?(String) && !path.empty? + rescue StandardError + end + ' "${DIAGNOSE_JSON}" 2>/dev/null || true)" + if [[ -z "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PATH="/Library/PrivilegedHelperTools/tech.reidar.vifty.daemon" + fi +fi + +INSTALLED_DAEMON_PRESENT="false" +INSTALLED_DAEMON_SHA256="" +EXPECTED_DAEMON_SHA256="" +DAEMON_MATCHES_EXPECTED="unknown" + +if [[ -f "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PRESENT="true" + INSTALLED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${INSTALLED_DAEMON_PATH}" | awk '{print $1}')" +fi + +if [[ -n "${EXPECTED_DAEMON_PATH}" ]]; then + EXPECTED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${EXPECTED_DAEMON_PATH}" | awk '{print $1}')" + if [[ -n "${INSTALLED_DAEMON_SHA256}" && "${INSTALLED_DAEMON_SHA256}" == "${EXPECTED_DAEMON_SHA256}" ]]; then + DAEMON_MATCHES_EXPECTED="true" + else + DAEMON_MATCHES_EXPECTED="false" + fi +fi + ruby -rjson -rfileutils - \ "${CAPABILITIES_JSON}" \ "${CAPABILITIES_STATUS}" \ diff --git a/scripts/check-manual-smoke-readiness.sh b/scripts/check-manual-smoke-readiness.sh index 94f3423e..0849a6ae 100755 --- a/scripts/check-manual-smoke-readiness.sh +++ b/scripts/check-manual-smoke-readiness.sh @@ -24,9 +24,11 @@ Options: This script only runs: viftyctl diagnose --json -It may also hash the installed LaunchDaemon helper and --expected-daemon when -provided. It does not call prepare, run, restore-auto, ViftyHelper, sudo, or -raw SMC tools. Exit 0 means the manual smoke preflight is ready. Exit 75 means +It hashes the installed daemon path reported by diagnose when available, +falling back to the legacy LaunchDaemon helper path, and hashes +--expected-daemon when provided. It does not call prepare, run, restore-auto, +ViftyHelper, sudo, or raw SMC tools. Exit 0 means the manual smoke preflight is +ready. Exit 75 means the manual smoke test must be skipped until the printed blockers are cleared. JSON output uses schemaID: https://vifty.local/schemas/manual-smoke-readiness.schema.json @@ -36,7 +38,7 @@ USAGE VIFTYCTL="${VIFTYCTL:-/Applications/Vifty.app/Contents/MacOS/viftyctl}" EXPECTED_DAEMON_PATH="${VIFTY_MANUAL_SMOKE_EXPECTED_DAEMON:-}" REQUIRE_DAEMON_MATCH="${VIFTY_MANUAL_SMOKE_REQUIRE_DAEMON_MATCH:-0}" -INSTALLED_DAEMON_PATH="${VIFTY_MANUAL_SMOKE_INSTALLED_DAEMON_PATH:-/Library/PrivilegedHelperTools/tech.reidar.vifty.daemon}" +INSTALLED_DAEMON_PATH="${VIFTY_MANUAL_SMOKE_INSTALLED_DAEMON_PATH:-}" JSON_OUTPUT=0 SUMMARY_PATH="${VIFTY_MANUAL_SMOKE_READINESS_SUMMARY:-}" @@ -114,6 +116,33 @@ if [[ ! -x "${VIFTYCTL}" ]]; then exit 69 fi +DIAGNOSE_JSON="$(mktemp "${TMPDIR:-/tmp}/vifty-manual-smoke-diagnose.XXXXXXXX.json")" +DIAGNOSE_STDERR="$(mktemp "${TMPDIR:-/tmp}/vifty-manual-smoke-diagnose.XXXXXXXX.stderr")" +trap 'rm -f "${DIAGNOSE_JSON}" "${DIAGNOSE_STDERR}"' EXIT + +set +e +if [[ "${VIFTY_TEST_SHELL_FIXTURES:-0}" == "1" ]]; then + /bin/sh "${VIFTYCTL}" diagnose --json > "${DIAGNOSE_JSON}" 2> "${DIAGNOSE_STDERR}" +else + "${VIFTYCTL}" diagnose --json > "${DIAGNOSE_JSON}" 2> "${DIAGNOSE_STDERR}" +fi +DIAGNOSE_STATUS=$? +set -e + +if [[ -z "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PATH="$(/usr/bin/ruby -rjson -e ' + begin + payload = JSON.parse(File.read(ARGV.fetch(0))) + path = payload.dig("daemonRuntime", "installedDaemonPath") + puts path if path.is_a?(String) && !path.empty? + rescue StandardError + end + ' "${DIAGNOSE_JSON}" 2>/dev/null || true)" + if [[ -z "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PATH="/Library/PrivilegedHelperTools/tech.reidar.vifty.daemon" + fi +fi + INSTALLED_DAEMON_PRESENT="false" INSTALLED_DAEMON_SHA256="" EXPECTED_DAEMON_SHA256="" @@ -133,19 +162,6 @@ if [[ -n "${EXPECTED_DAEMON_PATH}" ]]; then fi fi -DIAGNOSE_JSON="$(mktemp "${TMPDIR:-/tmp}/vifty-manual-smoke-diagnose.XXXXXXXX.json")" -DIAGNOSE_STDERR="$(mktemp "${TMPDIR:-/tmp}/vifty-manual-smoke-diagnose.XXXXXXXX.stderr")" -trap 'rm -f "${DIAGNOSE_JSON}" "${DIAGNOSE_STDERR}"' EXIT - -set +e -if [[ "${VIFTY_TEST_SHELL_FIXTURES:-0}" == "1" ]]; then - /bin/sh "${VIFTYCTL}" diagnose --json > "${DIAGNOSE_JSON}" 2> "${DIAGNOSE_STDERR}" -else - "${VIFTYCTL}" diagnose --json > "${DIAGNOSE_JSON}" 2> "${DIAGNOSE_STDERR}" -fi -DIAGNOSE_STATUS=$? -set -e - ruby -rjson -rfileutils - \ "${DIAGNOSE_JSON}" \ "${DIAGNOSE_STATUS}" \ diff --git a/scripts/collect-agent-run-smoke-evidence.sh b/scripts/collect-agent-run-smoke-evidence.sh index 142dbdc9..6f19f952 100755 --- a/scripts/collect-agent-run-smoke-evidence.sh +++ b/scripts/collect-agent-run-smoke-evidence.sh @@ -68,7 +68,7 @@ SOURCE_ARTIFACT_SHA256="" SOURCE_ARTIFACT_BYTES="" EXPECTED_DAEMON_PATH="${VIFTY_AGENT_RUN_SMOKE_EXPECTED_DAEMON:-}" REQUIRE_DAEMON_MATCH="${VIFTY_AGENT_RUN_SMOKE_REQUIRE_DAEMON_MATCH:-0}" -INSTALLED_DAEMON_PATH="${VIFTY_AGENT_RUN_SMOKE_INSTALLED_DAEMON_PATH:-/Library/PrivilegedHelperTools/tech.reidar.vifty.daemon}" +INSTALLED_DAEMON_PATH="${VIFTY_AGENT_RUN_SMOKE_INSTALLED_DAEMON_PATH:-}" INSTALLED_DAEMON_PRESENT="false" INSTALLED_DAEMON_SHA256="" EXPECTED_DAEMON_SHA256="" @@ -349,29 +349,11 @@ child_command_kind() { VIFTYCTL_COMMAND_NAME="$(basename "${VIFTYCTL}")" VIFTYCTL_PATH_PRIVACY="basenameOnly" VIFTYCTL_PATH_KIND="$(classify_viftyctl_path_kind)" -SAFE_INSTALLED_DAEMON_PATH="$(share_safe_path_value "${INSTALLED_DAEMON_PATH}")" -INSTALLED_DAEMON_PATH_PRIVACY="$(share_safe_path_privacy "${INSTALLED_DAEMON_PATH}")" -SAFE_EXPECTED_DAEMON_PATH="$(share_safe_path_value "${EXPECTED_DAEMON_PATH}")" -EXPECTED_DAEMON_PATH_PRIVACY="$(share_safe_path_privacy "${EXPECTED_DAEMON_PATH}")" CHILD_COMMAND_NAME="$(/usr/bin/basename "${CHILD_COMMAND[0]}")" CHILD_COMMAND_KIND="$(child_command_kind "${CHILD_COMMAND[0]}")" CHILD_ARGUMENT_COUNT=$((${#CHILD_COMMAND[@]} - 1)) CHILD_ARGUMENTS_PRIVACY="omitted" -if [[ -f "${INSTALLED_DAEMON_PATH}" ]]; then - INSTALLED_DAEMON_PRESENT="true" - INSTALLED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${INSTALLED_DAEMON_PATH}" | awk '{print $1}')" -fi - -if [[ -n "${EXPECTED_DAEMON_PATH}" ]]; then - EXPECTED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${EXPECTED_DAEMON_PATH}" | awk '{print $1}')" - if [[ -n "${INSTALLED_DAEMON_SHA256}" && "${INSTALLED_DAEMON_SHA256}" == "${EXPECTED_DAEMON_SHA256}" ]]; then - DAEMON_MATCHES_EXPECTED="true" - else - DAEMON_MATCHES_EXPECTED="false" - fi -fi - if [[ -z "${OUTPUT_DIR}" ]]; then timestamp="$(date -u +"%Y%m%dT%H%M%SZ")" OUTPUT_DIR="${ROOT_DIR}/.build/vifty-agent-run-smoke-${timestamp}" @@ -1010,6 +992,39 @@ run_capture "pre-capabilities" "pre-capabilities.json" \ run_capture "pre-diagnose" "pre-diagnose.json" \ "${VIFTYCTL}" diagnose --json +if [[ -z "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PATH="$(/usr/bin/ruby -rjson -e ' + begin + payload = JSON.parse(File.read(ARGV.fetch(0))) + path = payload.dig("daemonRuntime", "installedDaemonPath") + puts path if path.is_a?(String) && !path.empty? + rescue StandardError + end + ' "${OUTPUT_DIR}/pre-diagnose.json" 2>/dev/null || true)" + if [[ -z "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PATH="/Library/PrivilegedHelperTools/tech.reidar.vifty.daemon" + fi +fi + +SAFE_INSTALLED_DAEMON_PATH="$(share_safe_path_value "${INSTALLED_DAEMON_PATH}")" +INSTALLED_DAEMON_PATH_PRIVACY="$(share_safe_path_privacy "${INSTALLED_DAEMON_PATH}")" +SAFE_EXPECTED_DAEMON_PATH="$(share_safe_path_value "${EXPECTED_DAEMON_PATH}")" +EXPECTED_DAEMON_PATH_PRIVACY="$(share_safe_path_privacy "${EXPECTED_DAEMON_PATH}")" + +if [[ -f "${INSTALLED_DAEMON_PATH}" ]]; then + INSTALLED_DAEMON_PRESENT="true" + INSTALLED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${INSTALLED_DAEMON_PATH}" | awk '{print $1}')" +fi + +if [[ -n "${EXPECTED_DAEMON_PATH}" ]]; then + EXPECTED_DAEMON_SHA256="$(/usr/bin/shasum -a 256 "${EXPECTED_DAEMON_PATH}" | awk '{print $1}')" + if [[ -n "${INSTALLED_DAEMON_SHA256}" && "${INSTALLED_DAEMON_SHA256}" == "${EXPECTED_DAEMON_SHA256}" ]]; then + DAEMON_MATCHES_EXPECTED="true" + else + DAEMON_MATCHES_EXPECTED="false" + fi +fi + pre_capabilities_status="$(command_status "pre-capabilities")" pre_diagnose_status="$(command_status "pre-diagnose")" capabilities_safe="$(capabilities_run_contract_safe "${OUTPUT_DIR}/pre-capabilities.json")" diff --git a/scripts/install-vifty.sh b/scripts/install-vifty.sh index e7d76eaa..fc6705cb 100755 --- a/scripts/install-vifty.sh +++ b/scripts/install-vifty.sh @@ -219,6 +219,7 @@ REPLACEMENT_TRANSACTION_ID="" REPLACEMENT_PREPARE_LIFECYCLE="" REPLACEMENT_PREPARE_LIFECYCLE_SHA256="" REPLACEMENT_STAGED_LIFECYCLE="" +REPLACEMENT_LIFECYCLE_CONTROL_APP="" REPLACEMENT_FINISH_ALLOWED=0 path_exists_without_following() { @@ -1384,7 +1385,8 @@ copy_stable_executable_to_run_dir() { [[ ! "${source_sha_before}" =~ ^[0-9a-f]{64}$ ]]; then return 1 fi - /bin/cp -p "${source}" "${private_copy}" || return 1 + # Copy executable bytes without propagating protected source flags. + /bin/cat "${source}" > "${private_copy}" || return 1 /bin/chmod 500 "${private_copy}" || return 1 [[ -f "${private_copy}" && -x "${private_copy}" && ! -L "${private_copy}" ]] || return 1 if ! private_sha="$(sha256_file "${private_copy}")" || @@ -1482,6 +1484,9 @@ prepare_replacement_authority_freeze() { --replacement-public-archive-sha256 "${PUBLIC_RELEASE_SHA256}" ) fi + if [[ -n "${REPLACEMENT_LIFECYCLE_CONTROL_APP}" ]]; then + prepare_arguments+=(--control-app "${REPLACEMENT_LIFECYCLE_CONTROL_APP}") + fi if "${REPLACEMENT_PREPARE_LIFECYCLE}" "${prepare_arguments[@]}"; then prepare_status=0 else @@ -1506,6 +1511,25 @@ prepare_replacement_authority_freeze() { REPLACEMENT_FINISH_ALLOWED=1 } +register_control_service() { + local app_path="$1" + local report="$2" + local executable="${app_path}/Contents/MacOS/Vifty" + [[ -x "${executable}" ]] || return 1 + "${executable}" --helper-service-management register --json >"${report}" || return 1 + /usr/bin/ruby -rjson -e ' + report = JSON.parse(File.read(ARGV.fetch(0))) + abort unless report["complete"] == true && report["state"] == "enabled" + ' "${report}" +} + +register_candidate_control_service() { + [[ -n "${REPLACEMENT_LIFECYCLE_CONTROL_APP}" ]] || return 0 + register_control_service \ + "${REPLACEMENT_LIFECYCLE_CONTROL_APP}" \ + "${RUN_DIR}/candidate-control-registration.json" +} + finish_replacement_authority_freeze() { local replacement_result="${1:-}" case "${replacement_result}" in installed|rolled-back) ;; *) return 75 ;; esac @@ -1537,6 +1561,10 @@ finish_replacement_authority_freeze() { echo "HARD FAILURE: replacement finish exited with status ${finish_status} and could not prove a frozen helper; preserving the verified new bundle instead of rolling back beneath possibly active authority." >&2 return 76 fi + if ! register_control_service "${DEST_APP}" "${RUN_DIR}/installed-control-registration.json"; then + echo "error: the installed destination could not re-register its daemon after replacement finish." >&2 + return 75 + fi REPLACEMENT_AUTHORITY_STATE="resumed" } @@ -1848,7 +1876,8 @@ preflight_existing_install_before_replacement() { echo "error: authenticated existing viftyctl changed during diagnosis; refusing replacement." >&2 exit 75 fi - if [[ "${existing_diagnose_status}" -eq 0 ]] && protocol_v2_replacement_evidence_passes "${existing_report}"; then + if [[ "${existing_diagnose_status}" -eq 0 || "${existing_diagnose_status}" -eq 75 ]] && + protocol_v2_replacement_evidence_passes "${existing_report}"; then REPLACEMENT_LIFECYCLE_APP="${DEST_APP}" echo "==> Existing authenticated ${existing_source_kind} install passed protocol-v2 Auto/System replacement preflight (diagnose exit ${existing_diagnose_status})." return 0 @@ -1986,6 +2015,17 @@ if [[ "${INSTALL_MODE}" == "public-release" ]]; then fi fi preflight_existing_install_before_replacement +# Recovery bootstrap: the live SMAppService record may point at the freshly +# built candidate after a stale-registration repair. Keep the destination app +# bound to the replacement ledger, but use that exact signed candidate only as +# the daemon-maintenance control client. +if [[ "${VIFTY_USE_CANDIDATE_LIFECYCLE:-0}" == "1" ]]; then + REPLACEMENT_LIFECYCLE_CONTROL_APP="${APP_DIR}" + if ! register_candidate_control_service; then + echo "error: the freshly built candidate could not re-register its daemon before replacement preparation." >&2 + exit 75 + fi +fi if [[ "${INSTALL_MODE}" == "public-release" ]]; then [[ "${PUBLIC_DESTINATION_EXPECTATION}" == "${PUBLIC_PRECHECK_DESTINATION_EXPECTATION}" ]] || { echo "error: public destination presence changed during replacement preflight." >&2 diff --git a/scripts/vifty-helper-lifecycle.sh b/scripts/vifty-helper-lifecycle.sh index 67fa612c..624c615d 100755 --- a/scripts/vifty-helper-lifecycle.sh +++ b/scripts/vifty-helper-lifecycle.sh @@ -3,6 +3,8 @@ set -euo pipefail OPERATION="" APP_PATH="${VIFTY_APP:-/Applications/Vifty.app}" +CONTROL_APP_PATH="" +CONTROL_APP_EXPLICIT=0 REPLACEMENT_PHASE="" REPLACEMENT_DESTINATION="" REPLACEMENT_TRANSACTION_ID="" @@ -119,6 +121,7 @@ usage() { cat >&2 <<'USAGE' Usage: vifty-helper-lifecycle.sh --operation repair|uninstall [--app /Applications/Vifty.app] + [--control-app /path/to/Vifty.app] [--dry-run] [--record command-record.json] [--replacement-phase prepare|finish --replacement-destination /Applications/Vifty.app @@ -138,7 +141,8 @@ authenticated daemon after quiesce, full-set Auto restoration, fresh readback, and token consumption. Only an explicit machine-readable protocol-mismatch report, or an exact helper-unreachable report paired with either a still-valid receipt or root re-verification of the published v1.3.2 daemon binary, may -select offline recovery; all other +select offline recovery. Ordinary uninstall also admits the exact signed public +v1.4.5 Auto-only helper after root re-verification. All other command errors, safety blockers, and malformed reports fail closed. Both paths enter the administrator/root boundary, disable and prove the exact launchd label offline, then run a root-staged, digest-bound, Developer-ID-verified Auto-only helper @@ -160,6 +164,7 @@ while [[ "$#" -gt 0 ]]; do case "$1" in --operation) require_value "$1" "${2:-}"; OPERATION="$2"; shift 2 ;; --app) require_value "$1" "${2:-}"; APP_PATH="${2%/}"; shift 2 ;; + --control-app) require_value "$1" "${2:-}"; CONTROL_APP_PATH="${2%/}"; CONTROL_APP_EXPLICIT=1; shift 2 ;; --record) require_value "$1" "${2:-}"; RECORD_PATH="$2"; shift 2 ;; --maintenance-report) require_value "$1" "${2:-}"; MAINTENANCE_REPORT="$2"; shift 2 ;; --replacement-phase) require_value "$1" "${2:-}"; REPLACEMENT_PHASE="$2"; shift 2 ;; @@ -184,6 +189,13 @@ done case "${OPERATION}" in repair|uninstall) ;; *) echo "helper-lifecycle: --operation must be repair or uninstall." >&2; exit 64 ;; esac case "${REPLACEMENT_PHASE}" in ""|prepare|finish|release-lock) ;; *) echo "helper-lifecycle: --replacement-phase must be prepare, finish, or release-lock." >&2; exit 64 ;; esac +if [[ "${CONTROL_APP_EXPLICIT}" -eq 1 ]] && { + [[ "${OPERATION}" != "uninstall" && + ! ( "${OPERATION}" == "repair" && "${REPLACEMENT_PHASE}" == "prepare" ) ]] +}; then + echo "helper-lifecycle: --control-app is only valid for uninstall or repair replacement prepare." >&2 + exit 64 +fi if [[ -n "${REPLACEMENT_PHASE}" ]]; then [[ "${OPERATION}" == "repair" && -n "${REPLACEMENT_DESTINATION}" && "${REPLACEMENT_DESTINATION}" == /* ]] || { echo "helper-lifecycle: replacement prepare/finish requires repair and an absolute replacement destination." >&2 @@ -206,7 +218,38 @@ if [[ -n "${REPLACEMENT_PHASE}" ]]; then } fi -APP_PATH="$(cd "$(/usr/bin/dirname "${APP_PATH}")" 2>/dev/null && pwd -P)/$(/usr/bin/basename "${APP_PATH}")" +app_parent="$(cd "$(/usr/bin/dirname "${APP_PATH}")" 2>/dev/null && pwd -P)" || { + echo "helper-lifecycle: target app parent is unavailable." >&2 + exit 66 +} +APP_PATH="${app_parent}/$(/usr/bin/basename "${APP_PATH}")" +if [[ -n "${CONTROL_APP_PATH}" ]]; then + control_app_parent="$(cd "$(/usr/bin/dirname "${CONTROL_APP_PATH}")" 2>/dev/null && pwd -P)" || { + echo "helper-lifecycle: control app parent is unavailable." >&2 + exit 66 + } + CONTROL_APP_PATH="${control_app_parent}/$(/usr/bin/basename "${CONTROL_APP_PATH}")" +else + CONTROL_APP_PATH="${APP_PATH}" +fi +[[ "$(/usr/bin/basename "${APP_PATH}")" == "Vifty.app" ]] || { + echo "helper-lifecycle: target app must be a Vifty.app bundle path." >&2 + exit 64 +} +if [[ "${CONTROL_APP_EXPLICIT}" -eq 1 ]]; then + [[ "${CONTROL_APP_PATH}" != "${APP_PATH}" ]] || { + echo "helper-lifecycle: --control-app must differ from the target --app." >&2 + exit 64 + } + [[ "$(/usr/bin/basename "${CONTROL_APP_PATH}")" == "Vifty.app" ]] || { + echo "helper-lifecycle: control app must be a Vifty.app bundle path." >&2 + exit 64 + } + [[ -d "${CONTROL_APP_PATH}" && ! -L "${CONTROL_APP_PATH}" ]] || { + echo "helper-lifecycle: control app must be a real Vifty.app directory." >&2 + exit 66 + } +fi if [[ -n "${REPLACEMENT_PHASE}" ]]; then replacement_parent="$(cd "$(/usr/bin/dirname "${REPLACEMENT_DESTINATION}")" 2>/dev/null && pwd -P)" || { echo "helper-lifecycle: replacement destination parent is unavailable." >&2 @@ -288,10 +331,10 @@ if [[ -n "${REPLACEMENT_PHASE}" ]]; then fi fi fi -VIFTY_CTL="${APP_PATH}/Contents/MacOS/viftyctl" -VIFTY_MAIN="${APP_PATH}/Contents/MacOS/Vifty" -VIFTY_HELPER="${APP_PATH}/Contents/MacOS/ViftyHelper" -VIFTY_DAEMON="${APP_PATH}/Contents/MacOS/ViftyDaemon" +VIFTY_CTL="${CONTROL_APP_PATH}/Contents/MacOS/viftyctl" +VIFTY_MAIN="${CONTROL_APP_PATH}/Contents/MacOS/Vifty" +VIFTY_HELPER="${CONTROL_APP_PATH}/Contents/MacOS/ViftyHelper" +VIFTY_DAEMON="${CONTROL_APP_PATH}/Contents/MacOS/ViftyDaemon" PLIST_NAME="tech.reidar.vifty.daemon.plist" SERVICE_LABEL="tech.reidar.vifty.daemon" RELEASE_TEAM_ID="X88J3853S2" @@ -300,8 +343,9 @@ DAEMON_SIGNING_ID="tech.reidar.vifty.daemon" V132_DAEMON_SHA256="7543c573528a57bb096b045b9a7476b1d4da4aef88b7cd8b54d4cd2ca5bf7dac" V132_DAEMON_CDHASH="c5613e3020d94de1d141917d7b950fc367a6e61a" V132_FIXTURE_DAEMON_SHA256="66f0b66e7ed10074476cbd239194adbe3e8cb49fca3e43a3fc5f6c7b81cdeea5" +PUBLIC_RECOVERY_HELPER_SHA256="4c467d99f7e59c2727f0e1a9b13de81772741d269b560ce6ca9fb605782f0d0f" -if [[ ! -d "${APP_PATH}" ]]; then +if [[ ! -d "${APP_PATH}" || -L "${APP_PATH}" ]]; then echo "helper-lifecycle: app bundle not found: ${APP_PATH}" >&2 exit 66 fi @@ -309,6 +353,7 @@ fi if [[ -n "${TEST_ROOT}" ]]; then TEST_ROOT="$(cd "${TEST_ROOT}" 2>/dev/null && pwd -P)" case "${APP_PATH}" in "${TEST_ROOT}"/*) ;; *) echo "helper-lifecycle: fixture app must remain under VIFTY_LIFECYCLE_TEST_ROOT." >&2; exit 65 ;; esac + case "${CONTROL_APP_PATH}" in "${TEST_ROOT}"/*) ;; *) echo "helper-lifecycle: fixture control app must remain under VIFTY_LIFECYCLE_TEST_ROOT." >&2; exit 65 ;; esac if [[ "${REPLACEMENT_PHASE}" == "prepare" ]]; then case "${REPLACEMENT_CANDIDATE_APP}" in "${TEST_ROOT}"/*) ;; *) echo "helper-lifecycle: fixture replacement candidate escaped the test root." >&2; exit 65 ;; esac case "${REPLACEMENT_PREVIOUS_APP}" in "${TEST_ROOT}"/*) ;; *) echo "helper-lifecycle: fixture previous bundle escaped the test root." >&2; exit 65 ;; esac @@ -398,7 +443,7 @@ write_record() { [[ -d "${record_dir}" ]] || { echo "helper-lifecycle: record directory does not exist: ${record_dir}" >&2; return 1; } RECORD_TMP="$(/usr/bin/mktemp "${RECORD_PATH}.tmp.XXXXXX")" /usr/bin/ruby -rjson -rdigest -e ' - operation, app, dry_run, status, blocker, phase_log, privileged_record, *planned = ARGV + operation, app, control_app, dry_run, status, blocker, phase_log, privileged_record, *planned = ARGV executed = File.file?(phase_log) ? File.readlines(phase_log, chomp: true).reject(&:empty?) : [] payload = { schemaVersion: 1, @@ -412,8 +457,9 @@ write_record() { executedPhases: executed, privilegedEvidencePath: privileged_record } + payload[:controlApp] = control_app unless control_app == app STDOUT.write(JSON.pretty_generate(payload)); STDOUT.write("\n") - ' "${OPERATION}" "${APP_PATH}" "${DRY_RUN}" "${STATUS}" "${BLOCKER}" "${PHASE_LOG}" "${ROOT_EXECUTION_RECORD}" "${PLANNED_PHASES[@]}" > "${RECORD_TMP}" + ' "${OPERATION}" "${APP_PATH}" "${CONTROL_APP_PATH}" "${DRY_RUN}" "${STATUS}" "${BLOCKER}" "${PHASE_LOG}" "${ROOT_EXECUTION_RECORD}" "${PLANNED_PHASES[@]}" > "${RECORD_TMP}" /bin/chmod 600 "${RECORD_TMP}" /bin/mv -f "${RECORD_TMP}" "${RECORD_PATH}" RECORD_TMP="" @@ -440,6 +486,38 @@ validate_prepare_report() { ' "$1" "$2" "$3" } +normalize_legacy_maintenance_report_dates() { + /usr/bin/ruby -rjson -e ' + path = ARGV.fetch(0) + report = JSON.parse(File.read(path)) + offset = 978_307_200 + now = Time.now.to_f + changed = false + normalize = lambda do |value| + next value unless value.is_a?(Numeric) + shifted = value + offset + if (now - shifted).abs <= 120 + changed = true + shifted + else + value + end + end + [report["token"], *Array(report["fanResults"])].each do |entry| + next unless entry.is_a?(Hash) + %w[issuedAt expiresAt freshConfirmationAt].each do |key| + entry[key] = normalize.call(entry[key]) if entry.key?(key) + end + end + exit 0 unless changed + File.open(path, File::WRONLY | File::TRUNC | File::NOFOLLOW) do |file| + file.write(JSON.generate(report)) + file.flush + file.fsync + end + ' "$1" +} + validate_protocol_mismatch_report() { /usr/bin/ruby -rjson -e ' report = JSON.parse(File.read(ARGV[0])); operation = ARGV[1] @@ -1180,6 +1258,48 @@ capture_bundle_binding() { ' "${app}" "${kind}" "${team_id}" "${main_id}" "${ctl_id}" "${daemon_id}" "${helper_id}" "${bundle_version}" "${bundle_build}" } +validate_control_app() { + [[ "${CONTROL_APP_EXPLICIT}" -eq 1 ]] || return 0 + case "${OPERATION}:${REPLACEMENT_PHASE}" in + uninstall:|repair:prepare) ;; + *) return 1 ;; + esac + if [[ "${OPERATION}:${REPLACEMENT_PHASE}" == "repair:prepare" ]]; then + [[ "${CONTROL_APP_PATH}" == "${REPLACEMENT_CANDIDATE_APP}" ]] || return 1 + fi + [[ -d "${CONTROL_APP_PATH}" && ! -L "${CONTROL_APP_PATH}" ]] || return 1 + [[ "$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "${CONTROL_APP_PATH}/Contents/Info.plist" 2>/dev/null)" == "tech.reidar.vifty" ]] || return 1 + + local binding + binding="$(capture_bundle_binding "${CONTROL_APP_PATH}")" || return 1 + local expected_helper_sha="${PUBLIC_RECOVERY_HELPER_SHA256}" + if [[ "${OPERATION}:${REPLACEMENT_PHASE}" == "repair:prepare" ]]; then + expected_helper_sha="" + fi + /usr/bin/ruby -rjson -e ' + binding = JSON.parse(ARGV.fetch(0)) + path, expected_team, expected_helper_sha, test_root = ARGV.drop(1) + identity = binding.fetch("identity") + expected_ids = { + "Vifty" => "tech.reidar.vifty", + "viftyctl" => "tech.reidar.vifty.ctl", + "ViftyDaemon" => "tech.reidar.vifty.daemon", + "ViftyHelper" => "tech.reidar.vifty.helper" + } + exit 75 unless binding["sourcePath"] == File.expand_path(path) && + identity["componentIdentifiers"] == expected_ids && + identity["componentSHA256"].is_a?(Hash) && + identity["componentSHA256"].keys.sort == expected_ids.keys.sort + if test_root.empty? + exit 75 unless identity["kind"] == "developer-id" && + identity["teamID"] == expected_team && + (expected_helper_sha.empty? || identity.dig("componentSHA256", "ViftyHelper") == expected_helper_sha) + else + exit 75 unless identity["kind"] == "adhoc" && identity["teamID"].nil? + end + ' "${binding}" "${CONTROL_APP_PATH}" "${RELEASE_TEAM_ID}" "${expected_helper_sha}" "${TEST_ROOT}" +} + persist_root_record() { local record_status="$1" local record_blocker="${2:-}" @@ -1426,6 +1546,14 @@ stage_trusted_helper() { TRUSTED_HELPER="${staged}" } +stage_verified_public_recovery_helper() { + # Only the published Auto-only recovery helper may recover an unreachable + # service without a daemon receipt. Replacement and repair stay receipt-gated. + [[ "${OPERATION}" == "uninstall" && -z "${REPLACEMENT_PHASE}" ]] || return 1 + stage_trusted_helper "$1" || return 1 + [[ "${HELPER_SNAPSHOT_SHA256}" == "4c467d99f7e59c2727f0e1a9b13de81772741d269b560ce6ca9fb605782f0d0f" ]] +} + stage_verified_legacy_v132_daemon() { local daemon_dir="$1" local staged="${daemon_dir}/ViftyDaemon.v1.3.2" @@ -1608,7 +1736,9 @@ root_worker() { esac ROOT_AUTHORITY_MODE="${authority_mode}" if [[ "${requires_legacy_v132}" -eq 1 ]]; then - stage_verified_legacy_v132_daemon "${local_tmp}" || root_fail "The installed daemon is not the exact published Developer ID v1.3.2 compatibility binary." + stage_verified_legacy_v132_daemon "${local_tmp}" || + stage_verified_public_recovery_helper "${local_tmp}" || + root_fail "Unreachable helper recovery requires the exact published v1.3.2 daemon or the signed public Auto-only uninstall helper." fi record_root_phase verify-privileged-authority succeeded @@ -1715,6 +1845,7 @@ build_root_program() { builtin declare -f enable_and_confirm_service builtin declare -f stop_and_confirm_offline builtin declare -f stage_trusted_helper + builtin declare -f stage_verified_public_recovery_helper builtin declare -f stage_verified_legacy_v132_daemon builtin declare -f root_worker local variable @@ -2490,6 +2621,13 @@ if [[ "${REPLACEMENT_PHASE}" == "finish" ]]; then exit 0 fi +if ! validate_control_app; then + BLOCKER="The explicit control app failed the complete bundle, identifier, signature, TeamID, or pinned helper digest check." + write_record || true + echo "helper-lifecycle: ${BLOCKER}" >&2 + exit 75 +fi + if [[ -n "${MAINTENANCE_REPORT}" ]]; then BLOCKER="Caller-supplied maintenance reports cannot authorize live teardown; prepare must run in this invocation." PHASE_LOG="/dev/null" @@ -2536,6 +2674,10 @@ PREPARE_STATUS=$? set -e if [[ "${PREPARE_STATUS}" -eq 0 ]]; then /bin/chmod 600 "${REPORT_PATH}" + if ! normalize_legacy_maintenance_report_dates "${REPORT_PATH}"; then + cancel_unconsumed + fail_prepare_or_root "The daemon maintenance report could not be normalized or read safely." + fi if ! validate_prepare_report "${REPORT_PATH}" "${OPERATION}" "${HELPER_SNAPSHOT_SHA256}"; then cancel_unconsumed fail_prepare_or_root "The daemon maintenance report was incomplete, stale, or unsafe."