From 840fb14449e7f08001e2d547982c137b6782bc36 Mon Sep 17 00:00:00 2001 From: SarthakWade Date: Sat, 12 Sep 2026 02:35:30 +0530 Subject: [PATCH] feat: add typed settings registry --- README.md | 15 +- apps/headless/Sources/HeadlessCLI/main.swift | 93 +-- .../Sources/HeadlessProtocol/CLI.swift | 42 +- .../HeadlessProtocol/Capabilities.swift | 11 + .../Sources/HeadlessProtocol/Settings.swift | 612 ++++++++++++++++++ .../Tests/HeadlessMCPTests/main.swift | 11 +- .../HeadlessProtocolTests/ProtocolTests.swift | 509 ++++++++++++++- apps/headless/Tests/linux-e2e.sh | 26 +- apps/headless/Tests/macos-e2e.sh | 26 +- apps/headless/docs/COMMANDS.md | 39 +- apps/headless/docs/P0.md | 24 +- docs/roadmap/architecture-decisions.md | 39 +- 12 files changed, 1345 insertions(+), 102 deletions(-) create mode 100644 apps/headless/Sources/HeadlessProtocol/Settings.swift diff --git a/README.md b/README.md index 1f43f6a..0911f46 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,23 @@ On macOS, agent startup opens visible browser windows behind the app currently in use. Change the persistent default with `headless config set startup-presentation foreground` or restore background startup with `headless config set startup-presentation background`; inspect it with `headless config -get startup-presentation`. `headless start --foreground` and `headless start +get startup-presentation`. Use `config list` to discover settings, `config +describe KEY` for type and policy metadata, and `config reset KEY` to restore a +built-in default. `headless start --foreground` and `headless start --background` are one-launch overrides. Settings and overrides apply only when launching a new host and do not reorder an already-running host. +Settings declare their value type, default, supported platforms, access class, +and when changes take effect. `agent-readable` settings can be inspected but +not changed by an agent, `agent-writable` settings can also be changed, and +`user-only` settings are omitted from every agent CLI operation. The registry +is local-only and is not available through MCP or the browser protocol. macOS +retains the existing `com.headless.app` / `AgentStartupPresentation` preference; +Linux uses a bounded, versioned file in a private XDG configuration directory. +Security invariants such as sandboxing, navigation restrictions, diagnostic +gates, download denial, and the absence of arbitrary JavaScript and TCP control +are fixed policy, not settings. + Inspection is progressively disclosed instead of forcing an entire page into an agent prompt. Start with `--context summary`, use `--context outline` to receive structural region references such as `@r4`, then inspect only that region with diff --git a/apps/headless/Sources/HeadlessCLI/main.swift b/apps/headless/Sources/HeadlessCLI/main.swift index c70cba8..489f192 100644 --- a/apps/headless/Sources/HeadlessCLI/main.swift +++ b/apps/headless/Sources/HeadlessCLI/main.swift @@ -21,52 +21,6 @@ private func printResponse(_ response: CommandResponse) throws { FileHandle.standardOutput.write(try ProtocolCodec.encodeLine(response)) } -private enum StartupPresentationPreference { - static let builtInDefault = AgentStartupPresentation.background - private static let domain = "com.headless.app" - private static let key = "AgentStartupPresentation" - - static var configured: AgentStartupPresentation? { - guard let defaults = UserDefaults(suiteName: domain), - let value = defaults.string(forKey: key) else { return nil } - return AgentStartupPresentation(rawValue: value) - } - - static var effective: AgentStartupPresentation { - configured ?? builtInDefault - } - - static func requireSupportedPlatform() throws { - #if !os(macOS) - throw StartupPresentationPreferenceError.unsupported - #endif - } - - static func set(_ presentation: AgentStartupPresentation) throws { - guard let defaults = UserDefaults(suiteName: domain) else { - throw StartupPresentationPreferenceError.unavailable - } - defaults.set(presentation.rawValue, forKey: key) - guard defaults.synchronize() else { - throw StartupPresentationPreferenceError.writeFailed - } - } -} - -private enum StartupPresentationPreferenceError: Error, Equatable, CustomStringConvertible { - case unsupported - case unavailable - case writeFailed - - var description: String { - switch self { - case .unsupported: return "Startup presentation preferences are supported only on macOS." - case .unavailable: return "Could not open the Headless preferences domain." - case .writeFailed: return "Could not persist the startup presentation preference." - } - } -} - private struct HostLauncher { let client = LocalSocketClient() @@ -76,7 +30,7 @@ private struct HostLauncher { func start(presentation: AgentStartupPresentation? = nil) throws -> CommandResponse { #if !os(macOS) - if presentation != nil { throw StartupPresentationPreferenceError.unsupported } + if presentation != nil { throw SettingsError.unsupportedPlatform("startup-presentation") } #endif if let response = ping(), response.ok { return response } #if os(Linux) @@ -91,7 +45,11 @@ private struct HostLauncher { var environment = ProcessInfo.processInfo.environment environment["HEADLESS_AGENT_HOST"] = "1" #if os(macOS) - let effectivePresentation = presentation ?? StartupPresentationPreference.effective + let configuredPresentation = try SettingsStore.production().effectiveRawValue("startup-presentation") + guard let storedPresentation = AgentStartupPresentation(rawValue: configuredPresentation) else { + throw SettingsError.corruptStorage + } + let effectivePresentation = presentation ?? storedPresentation #else let effectivePresentation = AgentStartupPresentation.background #endif @@ -251,21 +209,20 @@ do { #endif case .start(let presentation): try printResponse(try HostLauncher().start(presentation: presentation)) - case .getStartupPresentation: - try StartupPresentationPreference.requireSupportedPlatform() - let configured = StartupPresentationPreference.configured - printJSON(.object([ - "builtInDefault": .string(StartupPresentationPreference.builtInDefault.rawValue), - "configured": configured.map { .string($0.rawValue) } ?? .null, - "startupPresentation": .string(StartupPresentationPreference.effective.rawValue), - ])) - case .setStartupPresentation(let presentation): - try StartupPresentationPreference.requireSupportedPlatform() - try StartupPresentationPreference.set(presentation) - printJSON(.object([ - "startupPresentation": .string(presentation.rawValue), - "takesEffect": .string("next-host-start"), - ])) + case .config(let command): + let settings = try SettingsStore.production() + switch command { + case .list: + printJSON(try settings.list()) + case .describe(let key): + printJSON(try settings.describe(key)) + case .get(let key): + printJSON(try settings.get(key)) + case .set(let key, let value): + printJSON(try settings.set(key, rawValue: value)) + case .reset(let key): + printJSON(try settings.reset(key)) + } case .credentials(let command): try CredentialBrokerLauncher().run(command) } @@ -313,10 +270,16 @@ do { ) try? printResponse(response) exit(69) -} catch let error as StartupPresentationPreferenceError { +} catch let error as SettingsError { + let code: String + switch error { + case .unsupportedPlatform: code = "UNSUPPORTED_CAPABILITY" + case .unknownKey, .invalidValue, .accessDenied: code = "INVALID_CONFIGURATION" + case .insecureStorage, .corruptStorage, .operationFailed: code = "CONFIGURATION_FAILED" + } let response = CommandResponse.failure( id: "unknown", - code: error == .unsupported ? "UNSUPPORTED_CAPABILITY" : "CONFIGURATION_FAILED", + code: code, message: error.description ) try? printResponse(response) diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index b9cd269..b0b98c4 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -1,18 +1,25 @@ import Foundation -public enum AgentStartupPresentation: String, Equatable, Sendable { +public enum AgentStartupPresentation: String, CaseIterable, Equatable, Sendable { case background case foreground } +public enum ConfigCLICommand: Equatable, Sendable { + case list + case describe(String) + case get(String) + case set(key: String, value: String) + case reset(String) +} + public enum LocalCommand: Equatable, Sendable { case help case version case capabilities case runtime case start(presentation: AgentStartupPresentation?) - case getStartupPresentation - case setStartupPresentation(AgentStartupPresentation) + case config(ConfigCLICommand) case credentials(CredentialCLICommand) } @@ -104,16 +111,20 @@ public struct CLIParser { throw CLIParseError.invalidOption(arguments.first ?? "start") } case "config": + guard session == nil else { throw CLIParseError.invalidOption("--session") } switch arguments { - case ["get", "startup-presentation"]: - return CLIInvocation(local: .getStartupPresentation, jsonOutput: true) - case let values where values.count == 3 - && values[0] == "set" && values[1] == "startup-presentation": - let value = values[2] - guard let presentation = AgentStartupPresentation(rawValue: value) else { - throw CLIParseError.invalidOption(value) - } - return CLIInvocation(local: .setStartupPresentation(presentation), jsonOutput: true) + case ["list"]: + return CLIInvocation(local: .config(.list), jsonOutput: true) + case let values where values.count == 2 && values[0] == "describe": + return CLIInvocation(local: .config(.describe(values[1])), jsonOutput: true) + case let values where values.count == 2 && values[0] == "get": + return CLIInvocation(local: .config(.get(values[1])), jsonOutput: true) + case let values where values.count == 3 && values[0] == "set": + return CLIInvocation( + local: .config(.set(key: values[1], value: values[2])), jsonOutput: true + ) + case let values where values.count == 2 && values[0] == "reset": + return CLIInvocation(local: .config(.reset(values[1])), jsonOutput: true) default: throw CLIParseError.invalidOption(arguments.first ?? "config") } @@ -733,8 +744,8 @@ Commands: version | --version start [--background|--foreground] | status | stop | runtime profile clear - config get startup-presentation - config set startup-presentation background|foreground + config list | config describe KEY | config get KEY + config set KEY VALUE | config reset KEY credentials list [--origin URL] credentials add --origin URL --alias NAME --interactive credentials rename --origin URL --alias OLD --to NEW @@ -775,4 +786,7 @@ Commands: Global options: --session NAME target a named browser session -- stop parsing global options; quote multi-word fill values + +Settings: +\(SettingsRegistry.shared.helpLines.joined(separator: "\n")) """ diff --git a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift index c136948..2322ba7 100644 --- a/apps/headless/Sources/HeadlessProtocol/Capabilities.swift +++ b/apps/headless/Sources/HeadlessProtocol/Capabilities.swift @@ -179,8 +179,19 @@ public let capabilitiesDocument: JSONValue = { ]), "screenshotSeries": stringArray(["viewport", "section"]), "localCommands": stringArray([ + "config.describe", "config.get", "config.list", "config.reset", "config.set", "credentials.add", "credentials.list", "credentials.remove", "credentials.rename", ]), + "settings": .object([ + "definitions": .array(SettingsRegistry.shared.definitions.compactMap { definition in + definition.access == .userOnly ? nil : definition.document + }), + "storage": .object([ + "macos": .string("user-defaults"), + "linux": .string("private-xdg-config-file"), + ]), + "securityInvariantsConfigurable": .bool(false), + ]), "credentialVault": .object([ "supported": .bool(true), "backend": .string(credentialBackend), diff --git a/apps/headless/Sources/HeadlessProtocol/Settings.swift b/apps/headless/Sources/HeadlessProtocol/Settings.swift new file mode 100644 index 0000000..d3e883c --- /dev/null +++ b/apps/headless/Sources/HeadlessProtocol/Settings.swift @@ -0,0 +1,612 @@ +import Foundation + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +public enum SettingPlatform: String, CaseIterable, Sendable { + case macOS = "macos" + case linux + + public static var current: SettingPlatform { + #if os(macOS) + .macOS + #else + .linux + #endif + } +} + +public enum SettingAccessClass: String, Sendable { + case agentReadable = "agent-readable" + case agentWritable = "agent-writable" + case userOnly = "user-only" +} + +public enum SettingRestartBehavior: String, Sendable { + case immediate + case nextHostStart = "next-host-start" +} + +public enum SettingValueType: Equatable, Sendable { + case boolean + case integer(ClosedRange) + case string(maximumLength: Int) + case enumeration([String]) + + fileprivate func parse(_ rawValue: String) throws -> String { + switch self { + case .boolean: + guard rawValue == "true" || rawValue == "false" else { + throw SettingsError.invalidValue(rawValue) + } + case .integer(let range): + guard let value = Int(rawValue), String(value) == rawValue, range.contains(value) else { + throw SettingsError.invalidValue(rawValue) + } + case .string(let maximumLength): + guard !rawValue.isEmpty, rawValue.utf8.count <= maximumLength, + !rawValue.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw SettingsError.invalidValue(rawValue) + } + case .enumeration(let values): + guard values.contains(rawValue) else { throw SettingsError.invalidValue(rawValue) } + } + return rawValue + } + + fileprivate func jsonValue(_ rawValue: String) -> JSONValue { + switch self { + case .boolean: + return .bool(rawValue == "true") + case .integer: + return .number(Double(Int(rawValue) ?? 0)) + case .string, .enumeration: + return .string(rawValue) + } + } + + fileprivate var document: JSONValue { + switch self { + case .boolean: + return .object(["name": .string("boolean")]) + case .integer(let range): + return .object([ + "name": .string("integer"), + "minimum": .number(Double(range.lowerBound)), + "maximum": .number(Double(range.upperBound)), + ]) + case .string(let maximumLength): + return .object([ + "name": .string("string"), + "maximumLength": .number(Double(maximumLength)), + ]) + case .enumeration(let values): + return .object([ + "name": .string("enum"), + "allowedValues": .array(values.map(JSONValue.string)), + ]) + } + } + + fileprivate var displayName: String { + switch self { + case .boolean: return "boolean" + case .integer: return "integer" + case .string: return "string" + case .enumeration: return "enum" + } + } +} + +public struct SettingDefinition: Equatable, Sendable { + public let key: String + public let valueType: SettingValueType + public let defaultValue: String + public let platforms: Set + public let restartBehavior: SettingRestartBehavior + public let access: SettingAccessClass + public let summary: String + public let macOSStorageKey: String? + + public init( + key: String, + valueType: SettingValueType, + defaultValue: String, + platforms: Set, + restartBehavior: SettingRestartBehavior, + access: SettingAccessClass, + summary: String, + macOSStorageKey: String? = nil + ) { + self.key = key + self.valueType = valueType + self.defaultValue = defaultValue + self.platforms = platforms + self.restartBehavior = restartBehavior + self.access = access + self.summary = summary + self.macOSStorageKey = macOSStorageKey + } + + fileprivate func validated(_ rawValue: String) throws -> String { + try valueType.parse(rawValue) + } + + public var document: JSONValue { + .object([ + "key": .string(key), + "type": valueType.document, + "default": valueType.jsonValue(defaultValue), + "platforms": .array(platforms.sorted { $0.rawValue < $1.rawValue }.map { + .string($0.rawValue) + }), + "restartBehavior": .string(restartBehavior.rawValue), + "access": .string(access.rawValue), + "summary": .string(summary), + ]) + } +} + +public struct SettingsRegistry: Sendable { + public static let shared = SettingsRegistry(definitions: [ + SettingDefinition( + key: "startup-presentation", + valueType: .enumeration(AgentStartupPresentation.allCases.map(\.rawValue)), + defaultValue: AgentStartupPresentation.background.rawValue, + platforms: [.macOS], + restartBehavior: .nextHostStart, + access: .agentWritable, + summary: "Choose whether an agent-started macOS host activates in front of the current app.", + macOSStorageKey: "AgentStartupPresentation" + ), + ]) + + public let definitions: [SettingDefinition] + private let definitionsByKey: [String: SettingDefinition] + + public init(definitions: [SettingDefinition]) { + precondition(Set(definitions.map(\.key)).count == definitions.count, "Duplicate setting key") + for definition in definitions { + precondition((try? definition.validated(definition.defaultValue)) != nil, "Invalid setting default") + } + self.definitions = definitions.sorted { $0.key < $1.key } + definitionsByKey = Dictionary(uniqueKeysWithValues: definitions.map { ($0.key, $0) }) + } + + public func definition(for key: String) throws -> SettingDefinition { + guard let definition = definitionsByKey[key] else { throw SettingsError.unknownKey(key) } + return definition + } + + public var helpLines: [String] { + definitions.compactMap { definition in + guard definition.access != .userOnly else { return nil } + let values: String + if case .enumeration(let allowed) = definition.valueType { + values = allowed.joined(separator: "|") + } else { + values = definition.valueType.displayName + } + return " \(definition.key) \(values) [\(definition.platforms.sorted { $0.rawValue < $1.rawValue }.map(\.rawValue).joined(separator: ","))]" + } + } +} + +public enum SettingsCaller: Sendable { + case agent + case user +} + +public enum SettingsError: Error, Equatable, CustomStringConvertible { + case unknownKey(String) + case invalidValue(String) + case unsupportedPlatform(String) + case accessDenied(String) + case insecureStorage + case corruptStorage + case operationFailed(String) + + public var description: String { + switch self { + case .unknownKey(let key): return "Unknown setting: \(key)" + case .invalidValue: return "Invalid setting value" + case .unsupportedPlatform(let key): return "Setting \(key) is unsupported on this platform" + case .accessDenied(let key): return "Setting \(key) is not available to this caller" + case .insecureStorage: return "Settings storage is not private and owned by the current user" + case .corruptStorage: return "Settings storage is corrupt or uses an unsupported schema" + case .operationFailed(let operation): return "Settings \(operation) failed" + } + } +} + +public protocol SettingsBackend: AnyObject, Sendable { + func configuredValue(for definition: SettingDefinition) throws -> String? + func setConfiguredValue(_ value: String, for definition: SettingDefinition) throws + func resetConfiguredValue(for definition: SettingDefinition) throws +} + +public final class SettingsStore: @unchecked Sendable { + public let registry: SettingsRegistry + public let platform: SettingPlatform + private let backend: SettingsBackend + + public init(registry: SettingsRegistry = .shared, platform: SettingPlatform = .current, backend: SettingsBackend) { + self.registry = registry + self.platform = platform + self.backend = backend + } + + public static func production( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> SettingsStore { + #if os(macOS) + return SettingsStore(backend: try UserDefaultsSettingsBackend()) + #else + return SettingsStore(backend: try FileSettingsBackend(environment: environment)) + #endif + } + + public func list(caller: SettingsCaller = .agent) throws -> JSONValue { + let entries = try registry.definitions.compactMap { definition -> JSONValue? in + guard canRead(definition, caller: caller) else { return nil } + return try settingDocument(definition, includeDescription: false) + } + return .object(["settings": .array(entries)]) + } + + public func describe(_ key: String, caller: SettingsCaller = .agent) throws -> JSONValue { + let definition = try visibleDefinition(key, caller: caller) + return try settingDocument(definition, includeDescription: true) + } + + public func get(_ key: String, caller: SettingsCaller = .agent) throws -> JSONValue { + let definition = try accessibleDefinition(key, caller: caller, write: false) + return try valueDocument(definition) + } + + public func effectiveRawValue(_ key: String, caller: SettingsCaller = .agent) throws -> String { + let definition = try accessibleDefinition(key, caller: caller, write: false) + return try configuredRawValue(definition) ?? definition.defaultValue + } + + public func set(_ key: String, rawValue: String, caller: SettingsCaller = .agent) throws -> JSONValue { + let definition = try accessibleDefinition(key, caller: caller, write: true) + let value = try definition.validated(rawValue) + try backend.setConfiguredValue(value, for: definition) + return mutationDocument(definition, value: value, configured: true) + } + + public func reset(_ key: String, caller: SettingsCaller = .agent) throws -> JSONValue { + let definition = try accessibleDefinition(key, caller: caller, write: true) + try backend.resetConfiguredValue(for: definition) + return mutationDocument(definition, value: definition.defaultValue, configured: false) + } + + private func accessibleDefinition( + _ key: String, caller: SettingsCaller, write: Bool + ) throws -> SettingDefinition { + let definition = try visibleDefinition(key, caller: caller) + guard definition.platforms.contains(platform) else { throw SettingsError.unsupportedPlatform(key) } + let allowed = write ? canWrite(definition, caller: caller) : canRead(definition, caller: caller) + guard allowed else { throw SettingsError.accessDenied(key) } + return definition + } + + private func visibleDefinition(_ key: String, caller: SettingsCaller) throws -> SettingDefinition { + let definition = try registry.definition(for: key) + guard caller == .user || definition.access != .userOnly else { + throw SettingsError.unknownKey(key) + } + return definition + } + + private func canRead(_ definition: SettingDefinition, caller: SettingsCaller) -> Bool { + caller == .user || definition.access != .userOnly + } + + private func canWrite(_ definition: SettingDefinition, caller: SettingsCaller) -> Bool { + if caller == .user { return true } + return definition.access == .agentWritable + } + + private func configuredRawValue(_ definition: SettingDefinition) throws -> String? { + guard let value = try backend.configuredValue(for: definition) else { return nil } + return try definition.validated(value) + } + + private func settingDocument(_ definition: SettingDefinition, includeDescription: Bool) throws -> JSONValue { + guard case .object(var object) = definition.document else { preconditionFailure() } + let configured = try configuredRawValue(definition) + object["value"] = definition.valueType.jsonValue(configured ?? definition.defaultValue) + object["configured"] = configured.map(definition.valueType.jsonValue) ?? .null + object["supportedOnCurrentPlatform"] = .bool(definition.platforms.contains(platform)) + if !includeDescription { object.removeValue(forKey: "summary") } + addCompatibilityFields(to: &object, definition: definition, configured: configured) + return .object(object) + } + + private func valueDocument(_ definition: SettingDefinition) throws -> JSONValue { + let configured = try configuredRawValue(definition) + var object: [String: JSONValue] = [ + "key": .string(definition.key), + "value": definition.valueType.jsonValue(configured ?? definition.defaultValue), + "default": definition.valueType.jsonValue(definition.defaultValue), + "configured": configured.map(definition.valueType.jsonValue) ?? .null, + ] + addCompatibilityFields(to: &object, definition: definition, configured: configured) + return .object(object) + } + + private func mutationDocument( + _ definition: SettingDefinition, value: String, configured: Bool + ) -> JSONValue { + var object: [String: JSONValue] = [ + "key": .string(definition.key), + "value": definition.valueType.jsonValue(value), + "configured": .bool(configured), + "takesEffect": .string(definition.restartBehavior.rawValue), + ] + if definition.key == "startup-presentation" { + object["startupPresentation"] = .string(value) + } + return .object(object) + } + + private func addCompatibilityFields( + to object: inout [String: JSONValue], definition: SettingDefinition, configured: String? + ) { + guard definition.key == "startup-presentation" else { return } + object["builtInDefault"] = .string(definition.defaultValue) + object["startupPresentation"] = .string(configured ?? definition.defaultValue) + } +} + +public final class UserDefaultsSettingsBackend: @unchecked Sendable, SettingsBackend { + private static let domain = "com.headless.app" + private static let canonicalPrefix = "HeadlessSetting." + private let defaults: UserDefaults + + public convenience init() throws { + try self.init(suiteName: Self.domain) + } + + public init(suiteName: String) throws { + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw SettingsError.operationFailed("preferences access") + } + self.defaults = defaults + } + + public func configuredValue(for definition: SettingDefinition) throws -> String? { + let key = storageKey(for: definition) + if let value = defaults.string(forKey: key) { return value } + guard defaults.object(forKey: key) == nil else { throw SettingsError.corruptStorage } + return nil + } + + public func setConfiguredValue(_ value: String, for definition: SettingDefinition) throws { + defaults.set(value, forKey: storageKey(for: definition)) + guard defaults.synchronize() else { throw SettingsError.operationFailed("write") } + } + + public func resetConfiguredValue(for definition: SettingDefinition) throws { + defaults.removeObject(forKey: storageKey(for: definition)) + guard defaults.synchronize() else { throw SettingsError.operationFailed("reset") } + } + + + private func storageKey(for definition: SettingDefinition) -> String { + definition.macOSStorageKey ?? Self.canonicalPrefix + definition.key + } +} + +private struct SettingsFile: Codable { + let schemaVersion: Int + let values: [String: String] +} + +public final class FileSettingsBackend: @unchecked Sendable, SettingsBackend { + public static let maximumFileBytes = 65_536 + + public let rootURL: URL + private let registry: SettingsRegistry + private static let fileName = "settings.json" + private static let lockName = "settings.lock" + + public convenience init( + environment: [String: String], registry: SettingsRegistry = .shared + ) throws { + let base: URL + if let configured = environment["XDG_CONFIG_HOME"] { + guard configured.hasPrefix("/") else { throw SettingsError.insecureStorage } + base = URL(fileURLWithPath: configured, isDirectory: true).standardizedFileURL + } else { + base = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config", isDirectory: true) + } + try self.init(rootURL: base.appendingPathComponent("headless", isDirectory: true), registry: registry) + } + + public init(rootURL: URL, registry: SettingsRegistry = .shared) throws { + let standardized = rootURL.standardizedFileURL + guard standardized.isFileURL, standardized.path.hasPrefix("/") else { + throw SettingsError.insecureStorage + } + self.rootURL = standardized + self.registry = registry + } + + public func configuredValue(for definition: SettingDefinition) throws -> String? { + try withLockedValues { values, _ in values[definition.key] } + } + + public func setConfiguredValue(_ value: String, for definition: SettingDefinition) throws { + try withLockedValues { values, directory in + values[definition.key] = value + try write(values, to: directory) + } + } + + public func resetConfiguredValue(for definition: SettingDefinition) throws { + try withLockedValues { values, directory in + values.removeValue(forKey: definition.key) + try write(values, to: directory) + } + } + + private func withLockedValues( + _ body: (inout [String: String], Int32) throws -> T + ) throws -> T { + let directory = try Self.openPrivateDirectory(rootURL) + defer { close(directory) } + let lock = Self.openLock(in: directory) + guard lock >= 0 else { throw SettingsError.insecureStorage } + defer { close(lock) } + try Self.validatePrivateRegularFile(lock) + guard flock(lock, LOCK_EX) == 0 else { throw SettingsError.operationFailed("lock") } + defer { _ = flock(lock, LOCK_UN) } + var values = try read(from: directory) + return try body(&values, directory) + } + + private func read(from directory: Int32) throws -> [String: String] { + let descriptor = openat(directory, Self.fileName, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + if descriptor < 0 { + if errno == ENOENT { return [:] } + throw SettingsError.insecureStorage + } + defer { close(descriptor) } + try Self.validatePrivateRegularFile(descriptor) + var info = stat() + guard fstat(descriptor, &info) == 0, info.st_size >= 0, + info.st_size <= Self.maximumFileBytes else { throw SettingsError.corruptStorage } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 8_192) + while true { + #if canImport(Darwin) + let count = Darwin.read(descriptor, &buffer, buffer.count) + #else + let count = Glibc.read(descriptor, &buffer, buffer.count) + #endif + if count < 0 && errno == EINTR { continue } + guard count >= 0 else { throw SettingsError.operationFailed("read") } + if count == 0 { break } + data.append(buffer, count: count) + guard data.count <= Self.maximumFileBytes else { throw SettingsError.corruptStorage } + } + guard let settings = try? JSONDecoder().decode(SettingsFile.self, from: data), + settings.schemaVersion == 1 else { throw SettingsError.corruptStorage } + guard let canonical = try? JSONEncoder.headlessSettingsEncoder.encode(settings), + canonical == data else { throw SettingsError.corruptStorage } + guard settings.values.count <= registry.definitions.count else { throw SettingsError.corruptStorage } + for (key, value) in settings.values { + guard let definition = try? registry.definition(for: key), + (try? definition.validated(value)) != nil else { + throw SettingsError.corruptStorage + } + } + return settings.values + } + + private func write(_ values: [String: String], to directoryDescriptor: Int32) throws { + let data = try JSONEncoder.headlessSettingsEncoder.encode( + SettingsFile(schemaVersion: 1, values: values) + ) + guard data.count <= Self.maximumFileBytes else { throw SettingsError.corruptStorage } + let temporaryName = ".settings.tmp-\(UUID().uuidString)" + let descriptor = openat( + directoryDescriptor, temporaryName, + O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC | O_NOFOLLOW, 0o600 + ) + guard descriptor >= 0 else { throw SettingsError.operationFailed("temporary file creation") } + var removeTemporary = true + defer { + close(descriptor) + if removeTemporary { _ = unlinkat(directoryDescriptor, temporaryName, 0) } + } + try Self.validatePrivateRegularFile(descriptor) + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + #if canImport(Darwin) + let count = Darwin.write(descriptor, base.advanced(by: offset), bytes.count - offset) + #else + let count = Glibc.write(descriptor, base.advanced(by: offset), bytes.count - offset) + #endif + if count < 0 && errno == EINTR { continue } + guard count > 0 else { throw SettingsError.operationFailed("write") } + offset += count + } + } + guard fsync(descriptor) == 0 else { throw SettingsError.operationFailed("sync") } + guard renameat(directoryDescriptor, temporaryName, directoryDescriptor, Self.fileName) == 0 else { + throw SettingsError.operationFailed("activation") + } + removeTemporary = false + guard fsync(directoryDescriptor) == 0 else { throw SettingsError.operationFailed("directory sync") } + } + + private static func openPrivateDirectory(_ url: URL) throws -> Int32 { + let parent = url.deletingLastPathComponent() + if parent.path != url.path, !FileManager.default.fileExists(atPath: parent.path) { + do { + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + } catch { + throw SettingsError.operationFailed("parent directory creation") + } + } + var descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_DIRECTORY) + if descriptor < 0, errno == ENOENT { + let created = mkdir(url.path, 0o700) == 0 + guard created || errno == EEXIST else { + throw SettingsError.operationFailed("directory creation") + } + descriptor = open(url.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_DIRECTORY) + if created, descriptor >= 0, fchmod(descriptor, 0o700) != 0 { + close(descriptor) + throw SettingsError.operationFailed("directory permissions") + } + } + guard descriptor >= 0 else { throw SettingsError.insecureStorage } + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == geteuid(), (info.st_mode & 0o077) == 0 else { + close(descriptor) + throw SettingsError.insecureStorage + } + return descriptor + } + + private static func openLock(in directory: Int32) -> Int32 { + // A concurrently created directory can be visible before its entries + // on some filesystems. Retry only the bounded first-use ENOENT case. + for attempt in 0..<20 { + let descriptor = openat( + directory, lockName, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600 + ) + if descriptor >= 0 || errno != ENOENT { return descriptor } + if attempt < 19 { usleep(1_000) } + } + return -1 + } + + private static func validatePrivateRegularFile(_ descriptor: Int32) throws { + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == geteuid(), info.st_nlink == 1, (info.st_mode & 0o077) == 0 else { + throw SettingsError.insecureStorage + } + guard fchmod(descriptor, 0o600) == 0 else { throw SettingsError.operationFailed("permissions") } + } +} + +private extension JSONEncoder { + static let headlessSettingsEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() +} diff --git a/apps/headless/Tests/HeadlessMCPTests/main.swift b/apps/headless/Tests/HeadlessMCPTests/main.swift index 9eb358e..28020db 100644 --- a/apps/headless/Tests/HeadlessMCPTests/main.swift +++ b/apps/headless/Tests/HeadlessMCPTests/main.swift @@ -66,6 +66,7 @@ func run() throws { #"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["credentials","add","synthetic-password"]}}}"#, #"{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["credentials","list"]}}}"#, #"{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["start"]}}}"#, + #"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"headless","arguments":{"argv":["config","list"]}}}"#, ] try process.run() @@ -81,7 +82,7 @@ func run() throws { let value = try JSONSerialization.jsonObject(with: Data(line.utf8)) return try object(value, "MCP response was not a JSON object") } - try expect(responses.count == 11, "expected eleven MCP responses, received \(responses.count)") + try expect(responses.count == 12, "expected twelve MCP responses, received \(responses.count)") let initialize = try object(responses[0]["result"], "initialize result was absent") try expect(initialize["protocolVersion"] as? String == "2025-06-18", "initialize protocol version changed") @@ -176,6 +177,14 @@ func run() throws { throw TestFailure(description: "local-command rejection text was absent") } try expect(localText.contains("browser commands only"), "local-command rejection guidance changed") + + let configCall = try object(responses[11]["result"], "config rejection result was absent") + try expect(configCall["isError"] as? Bool == true, "config command was accepted over MCP") + guard let configContent = configCall["content"] as? [[String: Any]], + let configText = configContent.first?["text"] as? String else { + throw TestFailure(description: "config rejection text was absent") + } + try expect(configText.contains("browser commands only"), "config rejection guidance changed") } do { diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 537ad89..d79e141 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1,5 +1,6 @@ import HeadlessProtocol import CredentialBrokerCore +import Dispatch import Foundation #if canImport(Darwin) import Darwin @@ -26,6 +27,65 @@ private func expectThrows(_ message: String, _ body: () throws -> Void) throws { } } +private func expectSettingsError( + _ expected: SettingsError, _ message: String, _ body: () throws -> Void +) throws { + do { + try body() + throw TestFailure(description: message) + } catch let error as SettingsError { + try expect(error == expected, "\(message): received \(error)") + } catch is TestFailure { + throw TestFailure(description: message) + } catch { + throw TestFailure(description: "\(message): received \(error)") + } +} + +private func settingsObject(_ value: JSONValue, _ message: String) throws -> [String: JSONValue] { + guard case .object(let object) = value else { throw TestFailure(description: message) } + return object +} + +private func settingsArray(_ value: JSONValue?, _ message: String) throws -> [JSONValue] { + guard case .array(let values)? = value else { throw TestFailure(description: message) } + return values +} + +private final class TestSettingsBackend: @unchecked Sendable, SettingsBackend { + private let lock = NSLock() + private var values: [String: String] + + init(values: [String: String] = [:]) { + self.values = values + } + + func configuredValue(for definition: SettingDefinition) throws -> String? { + lock.withLock { values[definition.key] } + } + + func setConfiguredValue(_ value: String, for definition: SettingDefinition) throws { + lock.withLock { values[definition.key] = value } + } + + func resetConfiguredValue(for definition: SettingDefinition) throws { + _ = lock.withLock { values.removeValue(forKey: definition.key) } + } +} + +private final class ConcurrentSettingsErrors: @unchecked Sendable { + private let lock = NSLock() + private var errors: [String] = [] + + func append(_ error: Error) { + lock.withLock { errors.append(String(describing: error)) } + } + + var messages: [String] { + lock.withLock { errors } + } +} + private func connectRawUnixSocket(path: String) throws -> Int32 { #if canImport(Darwin) let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) @@ -840,9 +900,13 @@ struct ProtocolTests { (["start"], .start(presentation: nil)), (["start", "--background"], .start(presentation: .background)), (["start", "--foreground"], .start(presentation: .foreground)), - (["config", "get", "startup-presentation"], .getStartupPresentation), - (["config", "set", "startup-presentation", "background"], .setStartupPresentation(.background)), - (["config", "set", "startup-presentation", "foreground"], .setStartupPresentation(.foreground)), + (["config", "get", "startup-presentation"], .config(.get("startup-presentation"))), + (["config", "set", "startup-presentation", "background"], .config(.set( + key: "startup-presentation", value: "background" + ))), + (["config", "set", "startup-presentation", "foreground"], .config(.set( + key: "startup-presentation", value: "foreground" + ))), (["credentials", "list"], .credentials(.list(origin: nil))), (["credentials", "list", "--origin", "https://example.com"], .credentials(.list( origin: try CredentialOrigin(rawValue: "https://example.com") @@ -866,9 +930,6 @@ struct ProtocolTests { try expectThrows("start should reject unknown options") { _ = try CLIParser().parse(["start", "--front"]) } - try expectThrows("startup presentation should reject unknown values") { - _ = try CLIParser().parse(["config", "set", "startup-presentation", "automatic"]) - } try expectThrows("credential commands must reject browser sessions") { _ = try CLIParser().parse(["--session", "qa", "credentials", "list"]) } @@ -890,6 +951,417 @@ struct ProtocolTests { } } + static func configCLICommandsAndArity() throws { + let commands: [([String], ConfigCLICommand)] = [ + (["config", "list"], .list), + (["config", "describe", "startup-presentation"], .describe("startup-presentation")), + (["config", "get", "startup-presentation"], .get("startup-presentation")), + (["config", "set", "startup-presentation", "foreground"], .set( + key: "startup-presentation", value: "foreground" + )), + (["config", "reset", "startup-presentation"], .reset("startup-presentation")), + ] + for (arguments, command) in commands { + let invocation = try CLIParser().parse(arguments) + try expect( + invocation.local == .config(command) && invocation.request == nil, + "\(arguments.joined(separator: " ")) should remain local" + ) + try expect(invocation.jsonOutput, "config commands should always produce JSON") + + var withSession = arguments + withSession.insert(contentsOf: ["--session", "qa"], at: 0) + try expectThrows("\(arguments[1]) should reject browser sessions") { + _ = try CLIParser().parse(withSession) + } + } + + let invalidCommands = [ + ["config"], + ["config", "list", "extra"], + ["config", "describe"], + ["config", "describe", "startup-presentation", "extra"], + ["config", "get"], + ["config", "get", "startup-presentation", "extra"], + ["config", "set"], + ["config", "set", "startup-presentation"], + ["config", "set", "startup-presentation", "foreground", "extra"], + ["config", "reset"], + ["config", "reset", "startup-presentation", "extra"], + ["config", "unknown"], + ] + for arguments in invalidCommands { + try expectThrows("config should reject invalid arity: \(arguments.joined(separator: " "))") { + _ = try CLIParser().parse(arguments) + } + } + try expectThrows("a literal session option must not bypass config arity validation") { + _ = try CLIParser().parse(["config", "list", "--", "--session", "qa"]) + } + } + + static func settingsRegistryAndAccess() throws { + let platforms: Set = [.macOS, .linux] + let definitions = [ + SettingDefinition( + key: "z-string", valueType: .string(maximumLength: 8), defaultValue: "value", + platforms: platforms, restartBehavior: .immediate, access: .agentWritable, + summary: "Bounded string" + ), + SettingDefinition( + key: "a-boolean", valueType: .boolean, defaultValue: "false", + platforms: platforms, restartBehavior: .immediate, access: .agentWritable, + summary: "Boolean" + ), + SettingDefinition( + key: "m-integer", valueType: .integer(1...3), defaultValue: "2", + platforms: platforms, restartBehavior: .nextHostStart, access: .agentReadable, + summary: "Bounded integer" + ), + SettingDefinition( + key: "n-enum", valueType: .enumeration(["first", "second"]), defaultValue: "first", + platforms: platforms, restartBehavior: .immediate, access: .agentWritable, + summary: "Enumeration" + ), + SettingDefinition( + key: "private-policy", valueType: .boolean, defaultValue: "false", + platforms: platforms, restartBehavior: .immediate, access: .userOnly, + summary: "User-only policy" + ), + ] + let registry = SettingsRegistry(definitions: definitions) + try expect( + registry.definitions.map(\.key) == [ + "a-boolean", "m-integer", "n-enum", "private-policy", "z-string", + ], + "registry definitions should have deterministic key order" + ) + try expect(registry.helpLines.count == 4, "agent help should omit user-only definitions") + try expect(registry.helpLines[0].contains("boolean"), "boolean metadata should reach generated help") + try expect(registry.helpLines[1].contains("integer"), "integer metadata should reach generated help") + try expect(registry.helpLines[2].contains("first|second"), "enum values should reach generated help") + try expect(registry.helpLines[3].contains("string"), "string metadata should reach generated help") + try expect( + !registry.helpLines.joined(separator: "\n").contains("private-policy"), + "user-only keys must not leak through generated agent help" + ) + + let backend = TestSettingsBackend() + let settings = SettingsStore(registry: registry, platform: .macOS, backend: backend) + let agentList = try settingsObject(try settings.list(), "settings list should be an object") + let agentEntries = try settingsArray(agentList["settings"], "settings list should contain an array") + let encodedAgentList = String( + decoding: try ProtocolCodec.encoder.encode(JSONValue.array(agentEntries)), as: UTF8.self + ) + try expect(agentEntries.count == 4, "agent listing should omit user-only settings") + try expect(!encodedAgentList.contains("private-policy"), "user-only keys must not leak through list") + try expectSettingsError(.unknownKey("private-policy"), "user-only describe should be indistinguishable from unknown") { + _ = try settings.describe("private-policy", caller: .agent) + } + try expectSettingsError(.unknownKey("private-policy"), "user-only get should be indistinguishable from unknown") { + _ = try settings.get("private-policy", caller: .agent) + } + try expectSettingsError(.unknownKey("private-policy"), "user-only set should be indistinguishable from unknown") { + _ = try settings.set("private-policy", rawValue: "true", caller: .agent) + } + try expectSettingsError(.unknownKey("private-policy"), "user-only reset should be indistinguishable from unknown") { + _ = try settings.reset("private-policy", caller: .agent) + } + try expectSettingsError(.accessDenied("m-integer"), "agent-readable settings must reject writes") { + _ = try settings.set("m-integer", rawValue: "3", caller: .agent) + } + try expectSettingsError(.accessDenied("m-integer"), "agent-readable settings must reject reset") { + _ = try settings.reset("m-integer", caller: .agent) + } + + let userList = try settingsObject(try settings.list(caller: .user), "user settings list should be an object") + try expect( + try settingsArray(userList["settings"], "user settings list should contain an array").count == 5, + "user callers should see user-only settings" + ) + _ = try settings.set("private-policy", rawValue: "true", caller: .user) + try expect(try settings.effectiveRawValue("private-policy", caller: .user) == "true", "user callers should mutate user-only settings") + + _ = try settings.set("a-boolean", rawValue: "true") + _ = try settings.set("n-enum", rawValue: "second") + _ = try settings.set("z-string", rawValue: "12345678") + try expectSettingsError(.invalidValue("TRUE"), "boolean values should be strict") { + _ = try settings.set("a-boolean", rawValue: "TRUE") + } + try expectSettingsError(.invalidValue("4"), "integers should remain bounded") { + _ = try settings.set("m-integer", rawValue: "4", caller: .user) + } + try expectSettingsError(.invalidValue("third"), "enums should reject unknown values") { + _ = try settings.set("n-enum", rawValue: "third") + } + try expectSettingsError(.invalidValue("123456789"), "strings should remain bounded") { + _ = try settings.set("z-string", rawValue: "123456789") + } + + let startupBackend = TestSettingsBackend() + let macSettings = SettingsStore(platform: .macOS, backend: startupBackend) + let listed = try settingsObject(try macSettings.list(), "startup list should be an object") + let startupEntries = try settingsArray(listed["settings"], "startup list should contain settings") + try expect(startupEntries.count == 1, "shared registry should expose one setting") + let defaultEntry = try settingsObject(startupEntries[0], "startup entry should be an object") + try expect(defaultEntry["startupPresentation"] == .string("background"), "list should preserve startupPresentation") + try expect(defaultEntry["builtInDefault"] == .string("background"), "list should preserve builtInDefault") + try expect(defaultEntry["configured"] == .null, "list should distinguish the built-in default") + try expect(defaultEntry["supportedOnCurrentPlatform"] == .bool(true), "list should report platform support") + + let described = try settingsObject(try macSettings.describe("startup-presentation"), "describe should be an object") + try expect(described["summary"] != nil, "describe should include the setting summary") + try expect(described["restartBehavior"] == .string("next-host-start"), "describe should expose restart behavior") + let initial = try settingsObject(try macSettings.get("startup-presentation"), "get should be an object") + try expect(initial["value"] == .string("background"), "get should return the default") + let changed = try settingsObject( + try macSettings.set("startup-presentation", rawValue: "foreground"), "set should be an object" + ) + try expect(changed["startupPresentation"] == .string("foreground"), "set should preserve startupPresentation") + try expect(changed["configured"] == .bool(true), "set should report configured state") + try expect(changed["takesEffect"] == .string("next-host-start"), "set should preserve takesEffect") + let configured = try settingsObject(try macSettings.get("startup-presentation"), "configured get should be an object") + try expect(configured["configured"] == .string("foreground"), "get should return the configured value") + let reset = try settingsObject(try macSettings.reset("startup-presentation"), "reset should be an object") + try expect(reset["startupPresentation"] == .string("background"), "reset should restore the default") + try expect(reset["configured"] == .bool(false), "reset should report an unconfigured value") + + let linuxSettings = SettingsStore(platform: .linux, backend: TestSettingsBackend()) + let linuxDescription = try settingsObject( + try linuxSettings.describe("startup-presentation"), "unsupported describe should remain discoverable" + ) + try expect(linuxDescription["supportedOnCurrentPlatform"] == .bool(false), "describe should report unsupported settings") + for operation in [ + { _ = try linuxSettings.get("startup-presentation") }, + { _ = try linuxSettings.set("startup-presentation", rawValue: "foreground") }, + { _ = try linuxSettings.reset("startup-presentation") }, + ] { + try expectSettingsError( + .unsupportedPlatform("startup-presentation"), + "known settings should fail explicitly on unsupported platforms", operation + ) + } + } + + static func userDefaultsSettingsCompatibility() throws { + let suite = "com.headless.tests.settings.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + throw TestFailure(description: "isolated UserDefaults suite should be available") + } + defaults.removePersistentDomain(forName: suite) + defer { + defaults.removePersistentDomain(forName: suite) + _ = defaults.synchronize() + } + defaults.set("foreground", forKey: "AgentStartupPresentation") + defaults.set("preserve-me", forKey: "LastURL") + try expect(defaults.synchronize(), "legacy defaults should synchronize") + + let backend = try UserDefaultsSettingsBackend(suiteName: suite) + let settings = SettingsStore(platform: .macOS, backend: backend) + try expect( + try settings.effectiveRawValue("startup-presentation") == "foreground", + "the physical legacy key should remain the canonical source" + ) + _ = try settings.set("startup-presentation", rawValue: "background") + try expect( + defaults.string(forKey: "AgentStartupPresentation") == "background", + "set should update the existing physical key" + ) + try expect(defaults.object(forKey: "HeadlessSetting.startup-presentation") == nil, "set should not create a shadow key") + _ = try settings.reset("startup-presentation") + try expect(defaults.object(forKey: "AgentStartupPresentation") == nil, "reset should remove the physical key") + try expect(defaults.string(forKey: "LastURL") == "preserve-me", "reset should preserve unrelated defaults") + try expect( + try settings.effectiveRawValue("startup-presentation") == "background", + "reset should not resurrect the legacy value" + ) + } + + static func fileSettingsBackendSecurityAndPersistence() throws { + func privateRoot(_ label: String) -> URL { + URL(fileURLWithPath: "/tmp/headless-settings-\(label)-\(UUID().uuidString)") + } + func writeRaw(_ text: String, root: URL) throws { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try expect(chmod(root.path, 0o700) == 0, "test root should be private") + let file = root.appendingPathComponent("settings.json") + try Data(text.utf8).write(to: file) + try expect(chmod(file.path, 0o600) == 0, "test settings file should be private") + } + func expectCorrupt(_ label: String, contents: String) throws { + let root = privateRoot(label) + defer { try? FileManager.default.removeItem(at: root) } + try writeRaw(contents, root: root) + let backend = try FileSettingsBackend(rootURL: root) + let settings = SettingsStore(platform: .macOS, backend: backend) + try expectSettingsError(.corruptStorage, "\(label) storage should fail closed") { + _ = try settings.get("startup-presentation") + } + } + + let xdgRoot = privateRoot("xdg") + defer { try? FileManager.default.removeItem(at: xdgRoot) } + let xdgBackend = try FileSettingsBackend(environment: ["XDG_CONFIG_HOME": xdgRoot.path]) + try expect( + xdgBackend.rootURL == xdgRoot.appendingPathComponent("headless", isDirectory: true), + "an absolute XDG config home should select the Headless settings directory" + ) + try expectSettingsError(.insecureStorage, "relative XDG config homes should fail closed") { + _ = try FileSettingsBackend(environment: ["XDG_CONFIG_HOME": "relative/config"]) + } + + let root = privateRoot("persistence") + defer { try? FileManager.default.removeItem(at: root) } + let first = SettingsStore( + platform: .macOS, backend: try FileSettingsBackend(rootURL: root) + ) + _ = try first.set("startup-presentation", rawValue: "foreground") + let second = SettingsStore( + platform: .macOS, backend: try FileSettingsBackend(rootURL: root) + ) + try expect(try second.effectiveRawValue("startup-presentation") == "foreground", "settings should persist across backend instances") + let rootMode = try FileManager.default.attributesOfItem(atPath: root.path)[.posixPermissions] as? NSNumber + let fileMode = try FileManager.default.attributesOfItem( + atPath: root.appendingPathComponent("settings.json").path + )[.posixPermissions] as? NSNumber + try expect(rootMode?.intValue == 0o700, "settings directory should be mode 0700") + try expect(fileMode?.intValue == 0o600, "settings file should be mode 0600") + _ = try second.reset("startup-presentation") + let third = SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: root)) + try expect(try third.effectiveRawValue("startup-presentation") == "background", "reset should persist the default state") + + try expectCorrupt("malformed", contents: "not-json") + try expectCorrupt("unknown-key", contents: #"{"schemaVersion":1,"values":{"unknown":"value"}}"#) + try expectCorrupt( + "invalid-value", contents: #"{"schemaVersion":1,"values":{"startup-presentation":"automatic"}}"# + ) + try expectCorrupt( + "unknown-schema", contents: #"{"schemaVersion":2,"values":{"startup-presentation":"foreground"}}"# + ) + try expectCorrupt( + "duplicate-key", + contents: #"{"schemaVersion":1,"values":{"startup-presentation":"background","startup-presentation":"foreground"}}"# + ) + + let oversizedRoot = privateRoot("oversized") + defer { try? FileManager.default.removeItem(at: oversizedRoot) } + try writeRaw(String(repeating: "x", count: FileSettingsBackend.maximumFileBytes + 1), root: oversizedRoot) + let oversized = SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: oversizedRoot)) + try expectSettingsError(.corruptStorage, "oversized storage should fail closed") { + _ = try oversized.get("startup-presentation") + } + + let linkedRoot = privateRoot("symlink-root") + let realRoot = privateRoot("symlink-target") + defer { + try? FileManager.default.removeItem(at: linkedRoot) + try? FileManager.default.removeItem(at: realRoot) + } + try FileManager.default.createDirectory(at: realRoot, withIntermediateDirectories: true) + try expect(chmod(realRoot.path, 0o700) == 0, "symlink target should be private") + try FileManager.default.createSymbolicLink(at: linkedRoot, withDestinationURL: realRoot) + let rootSymlink = SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: linkedRoot)) + try expectSettingsError(.insecureStorage, "symlinked settings roots should be rejected") { + _ = try rootSymlink.get("startup-presentation") + } + + let fileLinkRoot = privateRoot("symlink-file") + let linkTarget = privateRoot("file-target") + defer { + try? FileManager.default.removeItem(at: fileLinkRoot) + try? FileManager.default.removeItem(at: linkTarget) + } + let linkInitializer = SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: fileLinkRoot)) + _ = try linkInitializer.set("startup-presentation", rawValue: "foreground") + try FileManager.default.removeItem(at: fileLinkRoot.appendingPathComponent("settings.json")) + try Data(#"{"schemaVersion":1,"values":{}}"#.utf8).write(to: linkTarget) + try expect(chmod(linkTarget.path, 0o600) == 0, "symlink target file should be private") + try FileManager.default.createSymbolicLink( + at: fileLinkRoot.appendingPathComponent("settings.json"), withDestinationURL: linkTarget + ) + try expectSettingsError(.insecureStorage, "symlinked settings files should be rejected") { + _ = try linkInitializer.get("startup-presentation") + } + + let hardLinkRoot = privateRoot("hardlink") + let secondLink = privateRoot("hardlink-copy") + defer { + try? FileManager.default.removeItem(at: hardLinkRoot) + try? FileManager.default.removeItem(at: secondLink) + } + let hardLinkSettings = SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: hardLinkRoot)) + _ = try hardLinkSettings.set("startup-presentation", rawValue: "foreground") + try FileManager.default.linkItem( + at: hardLinkRoot.appendingPathComponent("settings.json"), to: secondLink + ) + try expectSettingsError(.insecureStorage, "multiply-linked settings files should be rejected") { + _ = try hardLinkSettings.get("startup-presentation") + } + + let permissiveRoot = privateRoot("permissive-root") + defer { try? FileManager.default.removeItem(at: permissiveRoot) } + try writeRaw(#"{"schemaVersion":1,"values":{}}"#, root: permissiveRoot) + try expect(chmod(permissiveRoot.path, 0o755) == 0, "test root should become permissive") + let permissiveRootSettings = SettingsStore( + platform: .macOS, backend: try FileSettingsBackend(rootURL: permissiveRoot) + ) + try expectSettingsError(.insecureStorage, "permissive settings roots should be rejected") { + _ = try permissiveRootSettings.get("startup-presentation") + } + + let permissiveFileRoot = privateRoot("permissive-file") + defer { try? FileManager.default.removeItem(at: permissiveFileRoot) } + try writeRaw(#"{"schemaVersion":1,"values":{}}"#, root: permissiveFileRoot) + try expect( + chmod(permissiveFileRoot.appendingPathComponent("settings.json").path, 0o644) == 0, + "test file should become permissive" + ) + let permissiveFileSettings = SettingsStore( + platform: .macOS, backend: try FileSettingsBackend(rootURL: permissiveFileRoot) + ) + try expectSettingsError(.insecureStorage, "permissive settings files should be rejected") { + _ = try permissiveFileSettings.get("startup-presentation") + } + } + + static func fileSettingsBackendConcurrentWriters() throws { + let root = URL(fileURLWithPath: "/tmp/headless-settings-concurrent-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let definitions = (0..<12).map { index in + SettingDefinition( + key: String(format: "writer-%02d", index), valueType: .integer(0...100), + defaultValue: "0", platforms: [.macOS, .linux], restartBehavior: .immediate, + access: .agentWritable, summary: "Concurrent writer" + ) + } + let registry = SettingsRegistry(definitions: definitions) + let errors = ConcurrentSettingsErrors() + DispatchQueue.concurrentPerform(iterations: definitions.count) { index in + do { + let backend = try FileSettingsBackend(rootURL: root, registry: registry) + let settings = SettingsStore(registry: registry, platform: .macOS, backend: backend) + _ = try settings.set(definitions[index].key, rawValue: String(index + 1)) + } catch { + errors.append(error) + } + } + try expect( + errors.messages.isEmpty, + "concurrent writers should all complete: \(errors.messages.joined(separator: ", "))" + ) + let reader = SettingsStore( + registry: registry, platform: .macOS, + backend: try FileSettingsBackend(rootURL: root, registry: registry) + ) + for (index, definition) in definitions.enumerated() { + try expect( + try reader.effectiveRawValue(definition.key) == String(index + 1), + "concurrent writes should not lose \(definition.key)" + ) + } + } + static func credentialCommandSecurity() throws { try expect( try CredentialOrigin(rawValue: "HTTPS://EXAMPLE.COM:443/").rawValue == "https://example.com", @@ -1507,6 +1979,9 @@ struct ProtocolTests { guard case .object(let document) = capabilitiesDocument, case .array(let rawCommands)? = document["commands"], case .object(let engines)? = document["engines"], + case .array(let localCommands)? = document["localCommands"], + case .object(let settings)? = document["settings"], + case .array(let settingDefinitions)? = settings["definitions"], case .object(let security)? = document["security"] else { throw TestFailure(description: "capabilities document shape") } @@ -1514,6 +1989,23 @@ struct ProtocolTests { let expected = CommandName.allCases.map(\.rawValue) try expect(commands.count == expected.count, "capabilities should not omit or duplicate commands") try expect(Set(commands) == Set(expected), "capabilities should match CommandName.allCases") + let localCommandNames = Set(localCommands.compactMap(\.stringValue)) + try expect( + localCommandNames.isSuperset(of: [ + "config.describe", "config.get", "config.list", "config.reset", "config.set", + ]), + "capabilities should advertise every local config command" + ) + try expect( + settingDefinitions == SettingsRegistry.shared.definitions.compactMap { + $0.access == .userOnly ? nil : $0.document + }, + "capability setting definitions should come from the registry" + ) + try expect( + settings["securityInvariantsConfigurable"] == .bool(false), + "capabilities must keep security invariants outside settings" + ) try expect( engines.count == BrowserEngineName.allCases.count, "capabilities should contain exactly one profile for every engine" @@ -2280,6 +2772,11 @@ struct ProtocolTests { ("CLI P1 artifacts", cliP1Artifacts), ("CLI P2 commands and boundaries", cliP2CommandsAndBoundaries), ("CLI command matrix", cliCommandMatrix), + ("config CLI commands and arity", configCLICommandsAndArity), + ("settings registry and access", settingsRegistryAndAccess), + ("UserDefaults settings compatibility", userDefaultsSettingsCompatibility), + ("file settings backend security and persistence", fileSettingsBackendSecurityAndPersistence), + ("file settings backend concurrent writers", fileSettingsBackendConcurrentWriters), ("credential command security", credentialCommandSecurity), ("credential vault lifecycle", credentialVaultLifecycle), ("credential confirmation", credentialVaultRejectsMismatchedConfirmation), diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index 0d7817d..e765e95 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -3,6 +3,7 @@ set -eu export HEADLESS_ARTIFACT_DIR="/tmp/headless-artifacts-e2e-$$" export XDG_DATA_HOME="/tmp/headless-data-e2e-$$" +export XDG_CONFIG_HOME="/tmp/headless-config-e2e-$$" FIXTURE_ROOT="$(mktemp -d /tmp/headless-fixture.XXXXXX)" INSTALL_ROOT="$(mktemp -d /tmp/headless-install.XXXXXX)" @@ -24,6 +25,7 @@ cleanup() { rm -rf "$INSTALL_ROOT" rm -rf "$HEADLESS_ARTIFACT_DIR" rm -rf "$XDG_DATA_HOME" + rm -rf "$XDG_CONFIG_HOME" } trap cleanup EXIT INT TERM @@ -66,11 +68,25 @@ if RELATIVE_RUNTIME="$(HEADLESS_CHROMIUM_EXECUTABLE=relative/chromium headless r fi echo "$RELATIVE_RUNTIME" | grep -q 'must be absolute' -if PRESENTATION_CONFIG="$(headless config get startup-presentation 2>&1)"; then - echo "macOS startup presentation configuration was accepted on Linux" >&2 - exit 1 -fi -echo "$PRESENTATION_CONFIG" | grep -q 'UNSUPPORTED_CAPABILITY' +SETTINGS_LIST="$(headless config list)" +echo "$SETTINGS_LIST" | grep -q '"key":"startup-presentation"' +echo "$SETTINGS_LIST" | grep -q '"access":"agent-writable"' +echo "$SETTINGS_LIST" | grep -q '"supportedOnCurrentPlatform":false' +SETTINGS_DESCRIPTION="$(headless config describe startup-presentation)" +echo "$SETTINGS_DESCRIPTION" | grep -q '"allowedValues":\["background","foreground"\]' +echo "$SETTINGS_DESCRIPTION" | grep -q '"supportedOnCurrentPlatform":false' +test "$(stat -c %a "$XDG_CONFIG_HOME/headless")" = "700" +test "$(stat -c %a "$XDG_CONFIG_HOME/headless/settings.lock")" = "600" +for PRESENTATION_COMMAND in \ + "get startup-presentation" \ + "set startup-presentation foreground" \ + "reset startup-presentation"; do + if PRESENTATION_CONFIG="$(headless config $PRESENTATION_COMMAND 2>&1)"; then + echo "macOS startup presentation configuration was accepted on Linux: $PRESENTATION_COMMAND" >&2 + exit 1 + fi + echo "$PRESENTATION_CONFIG" | grep -q 'UNSUPPORTED_CAPABILITY' +done if PRESENTATION_START="$(headless start --foreground 2>&1)"; then echo "macOS startup presentation override was accepted on Linux" >&2 exit 1 diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 71621e2..01b676f 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -144,8 +144,29 @@ restore_last_url echo "▸ unavailable restored URL fell back to the start page" STEP="start-host" -"$CLI" config set startup-presentation background | grep -q '"startupPresentation":"background"' -"$CLI" config get startup-presentation | grep -q '"startupPresentation":"background"' +SETTINGS_LIST="$("$CLI" config list)" +echo "$SETTINGS_LIST" | grep -q '"key":"startup-presentation"' +echo "$SETTINGS_LIST" | grep -q '"access":"agent-writable"' +echo "$SETTINGS_LIST" | grep -q '"supportedOnCurrentPlatform":true' +SETTINGS_DESCRIPTION="$("$CLI" config describe startup-presentation)" +echo "$SETTINGS_DESCRIPTION" | grep -q '"allowedValues":\["background","foreground"\]' +echo "$SETTINGS_DESCRIPTION" | grep -q '"restartBehavior":"next-host-start"' +echo "$SETTINGS_DESCRIPTION" | grep -q '"summary":"Choose whether an agent-started macOS host activates in front of the current app."' +RESET_PRESENTATION="$("$CLI" config reset startup-presentation)" +echo "$RESET_PRESENTATION" | grep -q '"configured":false' +echo "$RESET_PRESENTATION" | grep -q '"startupPresentation":"background"' +DEFAULT_PRESENTATION="$("$CLI" config get startup-presentation)" +echo "$DEFAULT_PRESENTATION" | grep -q '"builtInDefault":"background"' +echo "$DEFAULT_PRESENTATION" | grep -q '"configured":null' +echo "$DEFAULT_PRESENTATION" | grep -q '"startupPresentation":"background"' +SET_PRESENTATION="$("$CLI" config set startup-presentation background)" +echo "$SET_PRESENTATION" | grep -q '"configured":true' +echo "$SET_PRESENTATION" | grep -q '"takesEffect":"next-host-start"' +echo "$SET_PRESENTATION" | grep -q '"startupPresentation":"background"' +test "$(defaults read "$DEFAULTS_DOMAIN" "$PRESENTATION_KEY")" = "background" +CONFIGURED_PRESENTATION="$("$CLI" config get startup-presentation)" +echo "$CONFIGURED_PRESENTATION" | grep -q '"configured":"background"' +echo "$CONFIGURED_PRESENTATION" | grep -q '"startupPresentation":"background"' START_RESULT="$("$CLI" start)" || { print -r -u2 -- "headless start failed:" print -r -u2 -- "$START_RESULT" @@ -389,6 +410,7 @@ for _ in {1..100}; do done "$CLI" config set startup-presentation foreground | grep -q '"startupPresentation":"foreground"' "$CLI" config get startup-presentation | grep -q '"startupPresentation":"foreground"' +test "$(defaults read "$DEFAULTS_DOMAIN" "$PRESENTATION_KEY")" = "foreground" FOREGROUND_RESULT="$("$CLI" start)" FOREGROUND_PID="$(echo "$FOREGROUND_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$FOREGROUND_PID" diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index 4f3bf98..c6d560d 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -23,8 +23,8 @@ headless -- --value # stop option parsing; literal values version | --version start [--background|--foreground] | status | stop | runtime profile clear -config get startup-presentation -config set startup-presentation background|foreground +config list | config describe KEY | config get KEY +config set KEY VALUE | config reset KEY session create [NAME] | session list | session close NAME capabilities ``` @@ -32,12 +32,45 @@ capabilities - `start` launches the host if it is not already running. `status` and `stop` control it afterwards. `runtime` reports which engine is active and where it came from. -- `config startup-presentation` is macOS only; other engines reject it. +- `config list` discovers agent-visible settings. `config describe KEY` reports + its type, default, platform scope, access class, effect timing, current value, + and whether the current platform supports it. `config get`, `set`, and + `reset` read, change, or restore a built-in default. +- `startup-presentation` is an `agent-writable` macOS enum with `background` + and `foreground` values. It takes effect on the next host start. Linux lists + and describes it as unsupported, then rejects `get`, `set`, and `reset` with + `UNSUPPORTED_CAPABILITY`. - Sessions are windows (macOS) or tabs (Linux) sharing **one browser profile**. Cookies and local storage are shared across sessions and survive host and machine restarts. `profile clear` closes every session and permanently removes normal-profile cookies, storage, caches, and permissions. +## Settings + +Settings are local CLI operations and never enter the browser protocol or MCP. +Each registry definition has a typed value, validated default, platform scope, +effect timing, and one access class: + +- `agent-readable` is visible to agent callers but cannot be changed by them. +- `agent-writable` is visible and mutable by agent callers. +- `user-only` is omitted from `list` and rejected as unknown by `describe`, + `get`, `set`, and `reset` through the agent CLI. A future trusted native or + OS-authenticated surface is required to access it. + +On macOS, the registry uses the `com.headless.app` preferences domain and keeps +the existing `AgentStartupPresentation` storage key, avoiding migration or +resurrection of a stale value. Linux uses +`$XDG_CONFIG_HOME/headless/settings.json`, falling back to +`~/.config/headless/settings.json`. The Linux backend is bounded and versioned, +requires current-user ownership with `0700` directory and `0600` regular files, +rejects links and malformed state, serializes writers, and atomically replaces +and synchronizes the file. + +Security invariants are not settings. The registry cannot enable arbitrary +JavaScript, a TCP listener, unsafe schemes, downloads, sandbox bypasses, +sensitive-diagnostic bypasses, or credential authorization. Those boundaries +remain fixed and fail closed. + ## Credential vault ```sh diff --git a/apps/headless/docs/P0.md b/apps/headless/docs/P0.md index e19bb57..9da65e0 100644 --- a/apps/headless/docs/P0.md +++ b/apps/headless/docs/P0.md @@ -32,12 +32,24 @@ until it has authentication, authorization, and transport security. On macOS, CLI and automatic agent startup show browser windows without activating Headless, so the user's current app stays in front. `headless config set startup-presentation foreground|background` changes the persistent default, -and `headless config get startup-presentation` reports the effective value. +`headless config get startup-presentation` reports the effective value, and +`config reset startup-presentation` restores `background`. `config list` and +`config describe KEY` expose typed schema metadata, including the default, +platform scope, access class, and next-host-start effect timing. `headless start --foreground` and `--background` override that default for one new host. Settings and overrides never reorder an already-running host. Direct GUI launches and windows created from the app's menu retain normal foreground behavior. Startup presentation configuration is unsupported on Linux. +Settings are local-only and do not enter the browser protocol or MCP. The +registry classifies definitions as `agent-readable`, `agent-writable`, or +`user-only`; agent operations omit and reject user-only definitions. macOS +retains the `com.headless.app` / `AgentStartupPresentation` preference. Linux +uses a bounded, versioned, current-user-owned XDG configuration file with +private permissions, no-follow file operations, locking, and atomic durable +writes. Security boundaries below are fixed policy and cannot be changed with +configuration. + ## Security boundaries - Socket access is limited to the current operating-system user. @@ -54,6 +66,8 @@ behavior. Startup presentation configuration is unsupported on Linux. - External application URL schemes and local-file navigation are rejected. - Every command has a finite deadline and a structured failure response. - Linux refuses root and does not disable Chromium's sandbox. +- Security invariants, credential authorization, and sensitive-diagnostic + gates are not settings. ## Recording contract @@ -65,11 +79,13 @@ recorder, OBS/FFmpeg, CI recorder, or a later built-in recorder. - Shared protocol suite: codec bounds, URL/identifier/parameter allowlists, CLI parsing, private socket permissions, live-socket replacement protection. -- macOS E2E: named session, semantic snapshot, isolated-world tamper test, +- macOS E2E: typed settings discovery and mutation compatibility, named + session, semantic snapshot, isolated-world tamper test, bounded hostile-page output, fill/press, external-scheme rejection, full-page tours, click/wait, back/reload, and recorder metadata. -- Linux E2E: the same portable flow under sandboxed Chromium plus an assertion - that the fixture server is the only TCP listener. +- Linux E2E: settings discovery with unsupported mutation checks, the same + portable flow under sandboxed Chromium, and an assertion that the fixture + server is the only TCP listener. ## Compatibility diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index b48b518..52cb02e 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -559,7 +559,7 @@ without a password manager. - Rebuilds and signing-identity changes may re-prompt or look like a different app. Document that. Do not invent a self-signed cert as public trust. -- Prompt injection can still *name* an alias. Per-use user presence is what +- Prompt injection can still _name_ an alias. Per-use user presence is what makes that fail closed on this tier. - Login cookies are as stealable as in any persistent browser. Treat them as session secrets in the threat model, separate from vault passwords. @@ -616,6 +616,42 @@ new decision. Do not turn #166 on just because notarization started working. --- +## 25. Typed settings are local, classified, and policy-free + +**Decision:** application preferences use one typed registry that declares +each key's type, default, platform scope, effect timing, access class, storage +identity, and validation. The local CLI provides `config list`, `describe`, +`get`, `set`, and `reset`; these commands never enter the browser protocol or +MCP surface. Agent callers cannot discover user-only keys, cannot mutate +agent-readable keys, and can mutate only agent-writable keys. A future trusted +native surface may operate as the user, but ordinary CLI or PTY presence is not +proof of a human. + +macOS stores preferences in the existing `com.headless.app` UserDefaults +domain. The initial `startup-presentation` definition deliberately retains its +existing physical `AgentStartupPresentation` key, so adopting the registry +does not copy, lose, or resurrect a prior value. Linux uses a bounded, +versioned XDG config file under a `0700` Headless directory, with a `0600` +lock and data file, descriptor-relative no-follow operations, strict decoding, +locking, atomic replacement, and file plus directory synchronization. + +**Status:** implemented 2026-09-12 by +[#153](https://github.com/LockInTime/headless/issues/153). + +**Rationale:** settings need one discoverable contract before more preferences +arrive, but moving host security boundaries into a writable preference would +turn policy into an opt-out. Access classification is enforced below parsing, +not merely documented. User-only authorization remains in a trusted native or +broker path, and credential secrets and approvals remain outside this store. + +**Consequences:** safety invariants, diagnostic gates, sandbox behavior, +allowed navigation schemes, downloads, arbitrary JavaScript, remote control, +and credential authorization are not settings. Corrupt or insecure persisted +state fails closed. Adding a setting requires a registry entry and tests; a +wire-protocol version bump is unnecessary because config remains local-only. + +--- + ## Decision log | # | Decision | Status | Date | @@ -635,5 +671,6 @@ new decision. Do not turn #166 on just because notarization started working. | 20 | Omit passkeys unless Apple provisions Developer ID release | Implemented | 2026-08-12 | | 21 | Rust port of shared core, protocol layer first | In progress | 2026-08-22 | | 24 | Credential broker on the unsigned local tier | Decided | 2026-09-10 | +| 25 | Typed local settings registry; security policy stays fixed | Implemented | 2026-09-12 | New decisions append here with the same format. 22 and 23 are claimed by open PRs #170 and #169.