diff --git a/apps/ios/Sources/Keychain.swift b/apps/ios/Sources/Keychain.swift index 407c9141..5215cadc 100644 --- a/apps/ios/Sources/Keychain.swift +++ b/apps/ios/Sources/Keychain.swift @@ -14,8 +14,40 @@ // read/write/delete path needs no `#if os()` guards. import Foundation +import LocalAuthentication import Security +struct EphemeralConnectionCredentials: Equatable { + let endpoint: String + let deviceId: String + let deviceToken: String + + init(endpoint: String, deviceId: String, deviceToken: String) { + self.endpoint = endpoint + self.deviceId = deviceId + self.deviceToken = deviceToken + } + + init?(arguments: [String] = ProcessInfo.processInfo.arguments) { + func value(_ name: String) -> String? { + let prefix = "-\(name)=" + guard let argument = arguments.first(where: { $0.hasPrefix(prefix) }) else { + return nil + } + let value = String(argument.dropFirst(prefix.count)) + return value.isEmpty ? nil : value + } + + guard let endpoint = value("T4Endpoint"), + let deviceId = value("T4DeviceId"), + let deviceToken = value("T4DeviceToken") + else { return nil } + self.endpoint = endpoint + self.deviceId = deviceId + self.deviceToken = deviceToken + } +} + enum Keychain { /// Service prefix shared by every T4 Code keychain item (matches the /// `sh.t4code.ios` logger subsystem). @@ -29,6 +61,7 @@ enum Keychain { arguments: [String] = ProcessInfo.processInfo.arguments ) -> Bool { !arguments.contains("-T4NoRestore") + && EphemeralConnectionCredentials(arguments: arguments) == nil } /// Store `value` under `key`. Replaces any existing item. A nil or empty @@ -58,6 +91,8 @@ enum Keychain { /// Read the string stored under `key`, or nil if absent/unreadable. static func get(_ key: String) -> String? { guard usesPersistentStore() else { return nil } + let authenticationContext = LAContext() + authenticationContext.interactionNotAllowed = true let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -65,6 +100,10 @@ enum Keychain { kSecAttrSynchronizable as String: false, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, + // Ad-hoc macOS builds change signing identities frequently. Never + // block first paint on an interactive SecurityAgent consent sheet; + // an unreadable item behaves exactly like a missing item. + kSecUseAuthenticationContext as String: authenticationContext, ] var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, diff --git a/apps/ios/Sources/T4PanesView.swift b/apps/ios/Sources/T4PanesView.swift index bf9c7e23..dda6d734 100644 --- a/apps/ios/Sources/T4PanesView.swift +++ b/apps/ios/Sources/T4PanesView.swift @@ -15,7 +15,9 @@ // • artifact.read → ArtifactReadChunk { artifactId, kind, mediaType, size, // offset, nextOffset, complete, content (base64) } // • settings.read → SettingsReadResult { revision, settings {[String: JSONValue]} } -// • settings.write → opaque metadata object echo (confirmation-challenged). +// • settings.write → { written: true, revision: } (confirmation-challenged, +// expectedRevision required); partial settings object args, per-key merge for +// providerKeys (host masks values on read). import SwiftUI import HostWire @@ -580,12 +582,15 @@ struct T4ArtifactsPane: View { // MARK: - Settings pane -/// Host settings (settings.read): key/value list with inline string editing -/// via settings.write. Reads on appear; tapping a string row turns it into a -/// text field with a save button. Non-string values are read-only. Write -/// requires the settings revision (captured by settings.read) and may trigger -/// a confirmation challenge (handled by the store's pendingConfirmation -/// banner in the session detail view). +/// Host settings (settings.read / settings.write), sectioned: +/// MODELS (per-role model selector), BEHAVIOR (thinking level, tool approval), +/// PROVIDERS & KEYS (masked provider keys + add-key flow), CONNECTION +/// (endpoint, wss fingerprint + forget, disconnect), APPEARANCE (theme). +/// Reads on appear; every mutation goes through store.settingsWrite(patch:) +/// with the captured revision. The pane keeps a local optimistic mirror of +/// the snapshot — mutated immediately on write and reverted from the store on +/// failure. settings.write may trigger a confirmation challenge, surfaced as +/// store.pendingConfirmation by observe() and handled by the session banner. struct T4SettingsPane: View { @ObservedObject var store: T4SessionStore @EnvironmentObject var theme: ThemeStore @@ -593,13 +598,28 @@ struct T4SettingsPane: View { @State private var loading = true @State private var error: String? - @State private var editingKey: String? - @State private var editDraft = "" + /// Local optimistic mirror of store.settingsSnapshot — applied immediately + /// on write, reverted to the store's value on failure. + @State private var snapshot: [String: JSONValue] = [:] @State private var saving = false + /// Add-provider-key sheet state. + @State private var addingKey = false + @State private var newKeyProvider = "" + @State private var newKeyValue = "" private var t: Theme { theme.t } - private var settings: [String: JSONValue]? { store.settingsSnapshot } - private var sortedKeys: [String] { (settings?.keys).map { $0.sorted() } ?? [] } + // Known model roles (host modelRoles keys). "default" is the fallback when + // a role is unset, so it doubles as the clear sentinel in the picker. + private let roles: [(id: String, label: String)] = [ + ("default", "Default"), ("smol", "Smol"), ("slow", "Slow"), + ("vision", "Vision"), ("plan", "Plan"), ("designer", "Designer"), + ("commit", "Commit"), ("tiny", "Tiny"), ("task", "Task"), + ("advisor", "Advisor") + ] + private let thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max", "auto"] + private let approvalModes: [(id: String, label: String)] = [ + ("always-ask", "Always ask"), ("write", "Write"), ("yolo", "Yolo") + ] var body: some View { NavigationStack { @@ -613,7 +633,7 @@ struct T4SettingsPane: View { paneError(error, t) Spacer() } else { - settingsList(settings ?? [:]) + settingsList } } .background(t.bg.ignoresSafeArea()) @@ -627,39 +647,227 @@ struct T4SettingsPane: View { .font(.system(size: 14, weight: .semibold)) } ToolbarItem(placement: platformLeadingPlacement) { - Button { - Task { await load() } - } label: { + Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } .disabled(loading || !store.connected) } } + .sheet(isPresented: $addingKey) { + addKeySheet + } } .task { await load() } } - private func settingsList(_ settings: [String: JSONValue]) -> some View { - List { - hostSection - ForEach(sortedKeys, id: \.self) { key in - settingRow(key, settings[key] ?? .null) + private var settingsList: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + modelsSection + behaviorSection + providersSection + connectionSection + appearanceSection } + .padding(.horizontal, 18) + .padding(.vertical, 16) } - #if os(iOS) - .listStyle(.insetGrouped) - #else - .listStyle(.inset) - #endif .scrollContentBackground(.hidden) + .background(t.bg) + } + + /// One settings group: a labeled card of rows, no hairline dividers — the + /// card's padding does the grouping work the separators used to fake. + @ViewBuilder + private func settingsCard(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title.uppercased()) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.8) + .foregroundStyle(t.txtLabel) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 2) { content() } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(t.bg2) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(t.lineFaint, lineWidth: 0.5) + ) + } + } + + /// One row inside a card: breathing room instead of a separator. + @ViewBuilder + private func cardRow(@ViewBuilder content: () -> Content) -> some View { + HStack { content() } + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: MODELS + + @ViewBuilder + private var modelsSection: some View { + settingsCard("Models") { + if store.catalogModels.isEmpty { + Text("No models in catalog") + .font(.system(size: 12)) + .foregroundStyle(t.txtMuted) + } else { + ForEach(roles, id: \.id) { role in + cardRow { + Picker(selection: Binding( + get: { roleValue(role.id) }, + set: { new in Task { await setRole(role.id, new) } } + )) { + Text("default").tag("default") + ForEach(store.catalogModels, id: \.id) { m in + Text(m.id).tag(m.id) + } + } label: { + Text(role.label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(t.txt) + } + .pickerStyle(.menu) + .tint(t.interactiveAccent) + } + } + } + } + } + + // MARK: BEHAVIOR + + @ViewBuilder + private var behaviorSection: some View { + settingsCard("Behavior") { + cardRow { + Picker(selection: Binding( + get: { thinkingLevel() }, + set: { new in Task { await setThinkingLevel(new) } } + )) { + ForEach(thinkingLevels, id: \.self) { Text($0).tag($0) } + } label: { + Text("Thinking") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(t.txt) + } + .pickerStyle(.menu) + .tint(t.interactiveAccent) + } + + cardRow { + Picker(selection: Binding( + get: { approvalMode() }, + set: { new in Task { await setApprovalMode(new) } } + )) { + ForEach(approvalModes, id: \.id) { Text($0.label).tag($0.id) } + } label: { + Text("Tool approval") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(t.txt) + } + .pickerStyle(.menu) + .tint(t.interactiveAccent) + } + } } - /// Host identity/version + connection controls, always shown at the top - /// of the settings list so the user can inspect or cut the connection - /// even before settings.read succeeds. `store.hostInfo` is captured from - /// the WelcomeFrame on connect; `pairedEndpoint` is the live ws URL. + + // MARK: PROVIDERS & KEYS + @ViewBuilder - private var hostSection: some View { - Section(header: Text("Host").font(.system(size: 12, weight: .bold)).foregroundStyle(t.txtLabel)) { + private var providersSection: some View { + settingsCard("Providers & Keys") { + let keys = providerKeys() + if keys.isEmpty { + Text("No provider keys set") + .font(.system(size: 12)) + .foregroundStyle(t.txtMuted) + } else { + ForEach(keys, id: \.provider) { entry in + cardRow { + Text(entry.provider) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(t.txt) + Spacer() + Text(entry.masked) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(t.txtMuted) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + cardRow { + Button { + addingKey = true + } label: { + Label("Add provider key", systemImage: "plus") + .font(.system(size: 13, weight: .semibold)) + } + .tint(t.interactiveAccent) + .disabled(saving) + } + } + } + + /// Add-provider-key sheet: provider name + write-only SecureField. The key + /// is sent as a single-key providerKeys patch; the host masks on read, so + /// the saved row never shows the raw key. + private var addKeySheet: some View { + NavigationStack { + Form { + Section("Provider") { + TextField("e.g. openai", text: $newKeyProvider) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + Section("API key") { + SecureField("sk-…", text: $newKeyValue) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + if let error { + Section { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(t.cAdvisor) + } + } + } + .navigationTitle("Add provider key") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: platformLeadingPlacement) { + Button("Cancel") { addingKey = false } + } + ToolbarItem(placement: platformTrailingPlacement) { + Button("Save") { Task { await addKey() } } + .font(.system(size: 14, weight: .semibold)) + .disabled(saving || newKeyProvider.isEmpty || newKeyValue.isEmpty) + } + } + } + #if os(macOS) + .frame(minWidth: 360, minHeight: 240) + #endif + } + + // MARK: CONNECTION + + @ViewBuilder + private var connectionSection: some View { + settingsCard("Connection") { if let info = store.hostInfo { hostRow("Host ID", info.hostId) hostRow("OMP version", info.ompVersion) @@ -670,20 +878,67 @@ struct T4SettingsPane: View { hostRow("Appserver", "—") } hostRow("Endpoint", store.pairedEndpoint? + .replacingOccurrences(of: "wss://", with: "") .replacingOccurrences(of: "ws://", with: "") .replacingOccurrences(of: "/v1/ws", with: "") ?? "Not connected") - Button(role: .destructive) { - Task { await store.disconnect() } - } label: { - Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + if let fp = pinnedFingerprint { + cardRow { + Text("wss fingerprint") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(t.txt) + Spacer() + Text(String(fp.prefix(16)) + "…") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(t.txtMuted) + .lineLimit(1) + .truncationMode(.middle) + } + cardRow { + Button(role: .destructive) { + forgetPin() + } label: { + Label("Forget pinned cert", systemImage: "lock.slash") + .font(.system(size: 13)) + } + } + } + cardRow { + Button(role: .destructive) { + Task { await store.disconnect() } + } label: { + Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + } + .disabled(!store.connected) + } + } + } + + // MARK: APPEARANCE + + @ViewBuilder + private var appearanceSection: some View { + settingsCard("Appearance") { + cardRow { + Picker(selection: $theme.mode) { + Text("System").tag(Appearance.system) + Text("Dark").tag(Appearance.dark) + Text("Light").tag(Appearance.light) + } label: { + Text("Theme") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(t.txt) + } + .pickerStyle(.menu) + .tint(t.interactiveAccent) } - .disabled(!store.connected) } } + // MARK: Helpers + @ViewBuilder private func hostRow(_ key: String, _ value: String) -> some View { - HStack { + cardRow { Text(key) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(t.txt) @@ -696,91 +951,145 @@ struct T4SettingsPane: View { } } - @ViewBuilder - private func settingRow(_ key: String, _ value: JSONValue) -> some View { - if editingKey == key { - HStack(spacing: 8) { - TextField(key, text: $editDraft) - .font(.system(size: 13)) - .textFieldStyle(.plain) - .onSubmit { Task { await save(key) } } - if saving { - ProgressView().scaleEffect(0.7).tint(t.txtMuted) - } else { - Button("Save") { Task { await save(key) } } - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(t.accent) - } - Button("Cancel") { - editingKey = nil - editDraft = "" + /// modelRoles as a plain String→String map from the local snapshot. + private var modelRolesDict: [String: String] { + guard case .object(let o) = snapshot["modelRoles"] ?? .null else { return [:] } + return o.compactMapValues { v in + if case .string(let s) = v { return s } else { return nil } + } + } + + /// Current selector for a role, "default" when unset. + private func roleValue(_ role: String) -> String { + modelRolesDict[role] ?? "default" + } + + /// Write one role. Sends the full merged modelRoles object (values are not + /// masked, so round-tripping them is safe regardless of merge semantics). + private func setRole(_ role: String, _ selector: String) async { + var roles = modelRolesDict + if selector == "default" { roles.removeValue(forKey: role) } + else { roles[role] = selector } + let obj = roles.mapValues { JSONValue.string($0) } + let patch: [String: JSONValue] = roles.isEmpty + ? ["modelRoles": .object([:])] + : ["modelRoles": .object(obj)] + await write(patch) { s in + if roles.isEmpty { s.removeValue(forKey: "modelRoles") } + else { s["modelRoles"] = .object(obj) } + } + } + + private func thinkingLevel() -> String { + if case .string(let s) = snapshot["defaultThinkingLevel"] ?? .null { return s } + return "auto" + } + + private func setThinkingLevel(_ level: String) async { + await write(["defaultThinkingLevel": .string(level)]) { s in + s["defaultThinkingLevel"] = .string(level) + } + } + + private func approvalMode() -> String { + if case .string(let s) = snapshot["tools.approvalMode"] ?? .null { return s } + return "always-ask" + } + + private func setApprovalMode(_ mode: String) async { + await write(["tools.approvalMode": .string(mode)]) { s in + s["tools.approvalMode"] = .string(mode) + } + } + + /// Sorted (provider, masked) pairs from the local snapshot. + private func providerKeys() -> [(provider: String, masked: String)] { + guard case .object(let o) = snapshot["providerKeys"] ?? .null else { return [] } + return o.compactMap { k, v -> (provider: String, masked: String)? in + if case .string(let s) = v { return (provider: k, masked: s) } else { return nil } + }.sorted { $0.provider < $1.provider } + } + + /// Add a provider key. Sends a single-key providerKeys patch so the host + /// merges per-key — never echo masked values back, which would clobber the + /// real key. The re-read after write surfaces the host's masked form. + private func addKey() async { + let provider = newKeyProvider.trimmingCharacters(in: .whitespacesAndNewlines) + let value = newKeyValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !provider.isEmpty, !value.isEmpty else { return } + let patch: [String: JSONValue] = ["providerKeys": .object([provider: .string(value)])] + await write(patch) { s in + // Read existing masked keys from the in-progress snapshot so the + // optimistic row list includes the new key alongside the others. + var keys: [String: String] = [:] + if case .object(let o) = s["providerKeys"] ?? .null { + for (k, v) in o { + if case .string(let m) = v { keys[k] = m } } - .font(.system(size: 13)) - .foregroundStyle(t.txtMuted) } + keys[provider] = "sk-…\(value.suffix(4))" + s["providerKeys"] = .object(keys.mapValues { .string($0) }) + } + if error == nil { + addingKey = false + newKeyProvider = "" + newKeyValue = "" + } + } + + /// Optimistic write: mutate the local snapshot immediately, send the patch, + /// then sync from the store on success or revert on failure. + private func write( + _ patch: [String: JSONValue], + optimistic: (inout [String: JSONValue]) -> Void + ) async { + saving = true + error = nil + let backup = snapshot + var next = snapshot + optimistic(&next) + snapshot = next + let ok = await store.settingsWrite(patch: patch) + if ok { + snapshot = store.settingsSnapshot ?? [:] } else { - Button { - if case .string(let s) = value { - editingKey = key - editDraft = s - } - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(key) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(t.txt) - Text(valueLabel(value)) - .font(.system(size: 12)) - .foregroundStyle(t.txtMuted) - .lineLimit(2) - } - Spacer() - if case .string = value { - Image(systemName: "pencil") - .font(.system(size: 11)) - .foregroundStyle(t.txtLabel) - } - } - } - .buttonStyle(.plain) + snapshot = backup + if let err = store.lastError { error = err } } + saving = false + } + + /// Pinned wss leaf-cert fingerprint for the current endpoint, if any. + private var pinnedFingerprint: String? { + guard let endpoint = store.pairedEndpoint, + let comps = URLComponents(string: endpoint), + let host = comps.host else { return nil } + let port = comps.port ?? (comps.scheme == "wss" ? 443 : 80) + return T4CertPinner.pinnedFingerprint(host: host, port: port) + } + + private func forgetPin() { + guard let endpoint = store.pairedEndpoint, + let comps = URLComponents(string: endpoint), + let host = comps.host else { return } + let port = comps.port ?? (comps.scheme == "wss" ? 443 : 80) + T4CertPinner.forget(host: host, port: port) } private func load() async { loading = true error = nil if !store.connected { + snapshot = [:] loading = false return } - if await store.settingsRead() == nil, let err = store.lastError { - error = err - } - loading = false - } - - private func save(_ key: String) async { - saving = true - error = nil - let ok = await store.settingsWrite(key: key, value: .string(editDraft)) - if ok { - editingKey = nil - editDraft = "" + if let read = await store.settingsRead() { + snapshot = read } else if let err = store.lastError { error = err } - saving = false - } - - private func valueLabel(_ v: JSONValue) -> String { - switch v { - case .string(let s): return s - case .bool(let b): return b ? "true" : "false" - case .number(let n): return String(n) - case .null: return "null" - case .array, .object: return "…" - } + loading = false } } diff --git a/apps/ios/Sources/T4SessionStore.swift b/apps/ios/Sources/T4SessionStore.swift index 24a2f9d8..1ffc9fa4 100644 --- a/apps/ios/Sources/T4SessionStore.swift +++ b/apps/ios/Sources/T4SessionStore.swift @@ -286,7 +286,8 @@ final class T4SessionStore: ObservableObject { @Published private(set) var hasLiveInventory = false /// True when a previous session's endpoint is persisted (restore will run). var hasSavedConnection: Bool { - Keychain.get(Self.savedEndpointKey) != nil + EphemeralConnectionCredentials() != nil + || Keychain.get(Self.savedEndpointKey) != nil } /// True once a live host has spoken; false while showing the offline sample. @@ -570,6 +571,27 @@ final class T4SessionStore: ObservableObject { func restore() async { // UI-test seam: -T4NoRestore forces the offline sample inventory. if ProcessInfo.processInfo.arguments.contains("-T4NoRestore") { return } + // Harness seam: a complete endpoint/device/token triple is an + // in-memory connection profile. It never reads, writes, migrates, or + // deletes the developer's Keychain credentials. + if let ephemeral = EphemeralConnectionCredentials(), + let endpoint = URL(string: ephemeral.endpoint), + !connected, !connecting { + await connect( + endpoint: endpoint, + identity: ClientIdentity( + name: platformClientName, + version: "0.1", + build: "dev", + platform: platformClientPlatform + ), + authentication: DeviceAuthentication( + deviceId: ephemeral.deviceId, + deviceToken: ephemeral.deviceToken + ) + ) + return + } // UI-test seam: -T4ForgetCreds wipes saved connection credentials so // the boot lands on real onboarding (fresh-install path). if ProcessInfo.processInfo.arguments.contains("-T4ForgetCreds") { @@ -1112,6 +1134,13 @@ final class T4SessionStore: ObservableObject { return URLSessionHostWireTransport(endpoint: endpoint, session: session) } + /// A later healthy connection supersedes any earlier endpoint failure. + /// Keep this transition explicit so a transient restore/pair attempt cannot + /// leave a stale transport error pinned above a live session inventory. + func clearErrorAfterSuccessfulConnection() { + lastError = nil + } + /// Connect to a t4-host over host-wire, handshake, and load the inventory. func connect(endpoint: URL, identity: ClientIdentity, authentication: DeviceAuthentication? = nil) async { connecting = true @@ -1129,6 +1158,7 @@ final class T4SessionStore: ObservableObject { if connected { pairedEndpoint = endpoint.absoluteString persist(endpoint: endpoint, authentication: authentication) + clearErrorAfterSuccessfulConnection() await refresh() await loadCatalog() Task { await observe() } @@ -1175,6 +1205,7 @@ final class T4SessionStore: ObservableObject { connected = true pairedEndpoint = endpoint.absoluteString persist(endpoint: endpoint, authentication: nil) + clearErrorAfterSuccessfulConnection() await refresh() await loadCatalog() Task { await observe() } @@ -1189,7 +1220,7 @@ final class T4SessionStore: ObservableObject { deviceId: "\(platformDeviceIdPrefix)-\(slug)-\(UUID().uuidString.lowercased())", deviceName: deviceName, platform: platformClientPlatform, - requestedCapabilities: ["sessions.read", "sessions.prompt", "sessions.control", "sessions.manage", "catalog.read", "files.list", "files.read", "files.diff", "term.open", "term.input", "term.resize", "preview.control", "preview.read", "usage.read", "agents.control", "audit.read", "config.read"] + requestedCapabilities: ["sessions.read", "sessions.prompt", "sessions.control", "sessions.manage", "catalog.read", "files.list", "files.read", "files.diff", "term.open", "term.input", "term.resize", "preview.control", "preview.read", "usage.read", "agents.control", "audit.read", "config.read", "config.write"] ) let ok = try await c.pair(intent) // The paired connection is inert by design (the host rejects @@ -1844,6 +1875,19 @@ final class T4SessionStore: ObservableObject { } } + /// The wire's settings.read carries metadata entries ({type, effective}), + /// not bare values. Unwrap to the raw map the panes consume — entries that + /// are already bare (bridge hosts) pass through untouched. + private static func unwrapSettingsMetadata(_ settings: [String: JSONValue]) -> [String: JSONValue] { + settings.mapValues { value in + guard case .object(let entry) = value, + entry["type"] != nil, + let effective = entry["effective"] + else { return value } + return effective + } + } + /// Fetch the host settings map (settings.read, host scope). Stores the /// map in `settingsSnapshot` and returns it; nil on failure (lastError /// is set). @@ -1860,9 +1904,10 @@ final class T4SessionStore: ObservableObject { let result = try await client.sendCommand(CommandIntent( hostId: hostId, command: "settings.read")) let read = try result.settingsReadResult() - settingsSnapshot = read.settings + let settings = Self.unwrapSettingsMetadata(read.settings) + settingsSnapshot = settings settingsRevision = read.revision - return read.settings + return settings } catch { t4log.error("settings.read failed: \(error)") lastError = "\(error)" @@ -1870,25 +1915,22 @@ final class T4SessionStore: ObservableObject { } } - /// Write one host setting (settings.write, host scope, revision required). - /// The host echoes the written metadata object; on success the store - /// refreshes `settingsSnapshot` with the echo and returns true. Returns - /// false on failure (lastError is set). `value` is sent as a JSONValue - /// (string/bool/number); the pane shapes string values as `.string`. + /// Write a partial settings object (settings.write, host scope, revision + /// required). `patch` is merged into the host settings; the host may answer + /// with a confirmation challenge instead of a result — that surfaces as + /// `pendingConfirmation` via observe() and the banner handles approve/deny. + /// On success the new revision is captured and the snapshot re-read so + /// masked provider keys stay authoritative. @discardableResult - func settingsWrite(key: String, value: JSONValue) async -> Bool { + func settingsWrite(patch: [String: JSONValue]) async -> Bool { guard let client, connected, !hostId.isEmpty else { lastError = "Not connected to a host." return false } - // settings.write is host-scope, revision required, confirmation - // challenge. The revision is the settings revision captured by the - // last settings.read (`settingsRevision`); the host may answer with a - // confirmation challenge instead of a result — that surfaces as - // `pendingConfirmation` via observe() and the banner handles approve/ - // deny. Send without a confirmationId so the host issues the - // challenge; the store's pendingConfirmation banner then drives the - // confirm() flow. + guard grantedCapabilities.contains("config.write") else { + lastError = "Settings writes require the config.write capability." + return false + } guard let revision = settingsRevision else { lastError = "Settings revision unknown — load settings first." return false @@ -1896,10 +1938,13 @@ final class T4SessionStore: ObservableObject { do { let result = try await client.sendCommand(CommandIntent( hostId: hostId, command: "settings.write", - args: [key: value], expectedRevision: revision)) - if let echo = try result.settingsWriteResult() { - settingsSnapshot = echo + args: patch, expectedRevision: revision)) + if let echo = try result.settingsWriteResult(), + case .string(let newRevision) = echo["revision"] ?? .null, + !newRevision.isEmpty { + settingsRevision = newRevision } + await settingsRead() return true } catch { t4log.error("settings.write failed: \(error)") diff --git a/apps/ios/Tests/KeychainTests.swift b/apps/ios/Tests/KeychainTests.swift index bcce2a69..733b0f0a 100644 --- a/apps/ios/Tests/KeychainTests.swift +++ b/apps/ios/Tests/KeychainTests.swift @@ -13,6 +13,48 @@ final class KeychainTests: XCTestCase { XCTAssertTrue(Keychain.usesPersistentStore(arguments: ["T4Code"])) } + func testCompleteEphemeralCredentialsDisablePersistentKeychain() { + let arguments = [ + "T4Code", + "-T4Endpoint=wss://example.test/v1/ws", + "-T4DeviceId=device-id", + "-T4DeviceToken=device-token", + ] + + XCTAssertFalse(Keychain.usesPersistentStore(arguments: arguments)) + XCTAssertEqual( + EphemeralConnectionCredentials(arguments: arguments), + EphemeralConnectionCredentials( + endpoint: "wss://example.test/v1/ws", + deviceId: "device-id", + deviceToken: "device-token" + ) + ) + } + + func testIncompleteEphemeralCredentialsKeepPersistentKeychainEnabled() { + XCTAssertTrue( + Keychain.usesPersistentStore(arguments: [ + "T4Code", + "-T4Endpoint=wss://example.test/v1/ws", + "-T4DeviceId=device-id", + ]) + ) + } + + @MainActor + func testSuccessfulConnectionClearsStaleEndpointError() { + let store = T4SessionStore() + store.lastError = """ + Error Domain=NSURLErrorDomain Code=-1004 \ + "Could not connect to the server." + """ + + store.clearErrorAfterSuccessfulConnection() + + XCTAssertNil(store.lastError) + } + func testPendingTranscriptQueueKeepsEveryAssistantEntry() throws { var queue = PendingTranscriptQueue() @@ -60,6 +102,32 @@ final class KeychainTests: XCTestCase { XCTAssertFalse(defaults.bool(forKey: migrationMarkerKey)) } + @MainActor + func testEphemeralCredentialsPreserveLegacyCredentialsForLaterMigration() { + let suiteName = "KeychainTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set("wss://saved.example/v1/ws", forKey: "t4.endpoint") + defaults.set("saved-device-id", forKey: "t4.deviceId") + defaults.set("saved-device-token", forKey: "t4.deviceToken") + + T4SessionStore.migrateCredentialsToKeychainIfNeeded( + arguments: [ + "T4Code", + "-T4Endpoint=wss://ephemeral.example/v1/ws", + "-T4DeviceId=ephemeral-device-id", + "-T4DeviceToken=ephemeral-device-token", + ], + defaults: defaults + ) + + XCTAssertEqual(defaults.string(forKey: "t4.endpoint"), "wss://saved.example/v1/ws") + XCTAssertEqual(defaults.string(forKey: "t4.deviceId"), "saved-device-id") + XCTAssertEqual(defaults.string(forKey: "t4.deviceToken"), "saved-device-token") + let migrationMarkerKey = ["t4", "keychainMigrated"].joined(separator: ".") + XCTAssertFalse(defaults.bool(forKey: migrationMarkerKey)) + } + private func transcriptEntry( id: String, kind: String, diff --git a/packages/host-daemon/src/cli.ts b/packages/host-daemon/src/cli.ts index 9bfc5012..fe8b4c90 100644 --- a/packages/host-daemon/src/cli.ts +++ b/packages/host-daemon/src/cli.ts @@ -2,12 +2,13 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { createAppserver, createHostLogger, createRemoteAppserver, OfficialOmpProfileAuthority, + OmpSettingsAuthority, OmpAuthorityBridgeClient, profileSocketPath, FilesAuthority, @@ -33,6 +34,11 @@ const PROFILE = /^[a-z0-9][a-z0-9._-]{0,63}$/u; const ORIGIN_LIMIT = 32; const VERSION_OUTPUT_BYTES = 4 * 1024; const VERSION_TIMEOUT_MS = 5_000; + +export function officialOmpRootFromSessionsRoot(sessionsRoot: string): string { + const parent = dirname(sessionsRoot); + return basename(parent) === "agent" ? dirname(parent) : parent; +} const OFFICIAL_CATALOG_COMMANDS = Object.freeze([ "session.create", "session.rename", @@ -401,6 +407,9 @@ export async function runHostDaemon( items: officialCatalogItems(), }), ...terminals.operations(), + ...new OmpSettingsAuthority({ + ompRoot: officialOmpRootFromSessionsRoot(config.ompSessionsRoot!), + }).operations(), }; projectRootForProject = projectId => official.projectRootForProject(projectId); projectRootForSession = sessionId => official.projectRootForSession(sessionId); diff --git a/packages/host-daemon/src/pair.ts b/packages/host-daemon/src/pair.ts index c8172959..abfe5b72 100644 --- a/packages/host-daemon/src/pair.ts +++ b/packages/host-daemon/src/pair.ts @@ -11,7 +11,7 @@ const execFileAsync = promisify(execFile); * Capabilities requested on every pairing ticket. These match the read/prompt/ * manage surface the iOS companion needs to drive sessions over the wire. */ -const PAIR_CAPABILITIES = ["sessions.read", "sessions.prompt", "sessions.control", "sessions.manage", "catalog.read", "files.list", "files.read", "files.diff", "term.open", "term.input", "term.resize", "preview.control", "preview.read", "usage.read", "agents.control", "audit.read", "config.read"] as const; +const PAIR_CAPABILITIES = ["sessions.read", "sessions.prompt", "sessions.control", "sessions.manage", "catalog.read", "files.list", "files.read", "files.diff", "term.open", "term.input", "term.resize", "preview.control", "preview.read", "usage.read", "agents.control", "audit.read", "config.read", "config.write"] as const; /** Default ticket lifetime (10 minutes), matching the admin endpoint maximum. */ export const DEFAULT_PAIR_TTL_MS = 600_000; diff --git a/packages/host-daemon/test/cli.test.ts b/packages/host-daemon/test/cli.test.ts index 720f9b1a..e344ec65 100644 --- a/packages/host-daemon/test/cli.test.ts +++ b/packages/host-daemon/test/cli.test.ts @@ -6,6 +6,7 @@ import { hostDaemonPaths, OFFICIAL_OMP_BUILD, OFFICIAL_OMP_VERSION, + officialOmpRootFromSessionsRoot, parseHostDaemonArgs, runHostDaemon, verifyOfficialRuntime, @@ -31,6 +32,15 @@ describe("T4 host daemon CLI", () => { }); }); + test("keeps official settings inside the selected OMP profile root", () => { + expect( + officialOmpRootFromSessionsRoot("/profiles/test/.omp/agent/sessions"), + ).toBe("/profiles/test/.omp"); + expect( + officialOmpRootFromSessionsRoot("/isolated-profile/sessions"), + ).toBe("/isolated-profile"); + }); + test("validates remote exposure and rejects ambiguous or relative authority", () => { expect(() => parseHostDaemonArgs(["serve", "--omp", "omp"], "/home/test")).toThrow("absolute"); expect(() => diff --git a/packages/host-daemon/test/pair-cli.test.ts b/packages/host-daemon/test/pair-cli.test.ts index 2306306a..43ca1c4e 100644 --- a/packages/host-daemon/test/pair-cli.test.ts +++ b/packages/host-daemon/test/pair-cli.test.ts @@ -110,6 +110,7 @@ describe("t4-host pair", () => { expect(capabilities).toContain("agents.control"); expect(capabilities).toContain("audit.read"); expect(capabilities).toContain("config.read"); + expect(capabilities).toContain("config.write"); }); test("postPairTicket sends capabilities and ttlMs to the admin endpoint", async () => { diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 176abd33..992b9e3c 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -7,21 +7,38 @@ "license": "MIT", "main": "./src/index.ts", "types": "./src/index.ts", - "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" }, "./*": { "types": "./src/*.ts", "import": "./src/*.ts" } }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./*": { + "types": "./src/*.ts", + "import": "./src/*.ts" + } + }, "scripts": { - "build": "bun run typecheck", - "check": "bun run typecheck", - "test": "bun test test", - "verify:current-omp-bridge": "bun run bin/current-omp-bridge-proof.ts", - "verify:official-omp-lifecycle": "node ../../scripts/stage-omp-runtime.mjs --runtime official && bun run bin/official-omp-gate0.ts", - "typecheck": "tsgo -p tsconfig.json --noEmit" + "build": "bun run typecheck", + "check": "bun run typecheck", + "test": "bun test test", + "verify:current-omp-bridge": "bun run bin/current-omp-bridge-proof.ts", + "verify:official-omp-lifecycle": "node ../../scripts/stage-omp-runtime.mjs --runtime official && bun run bin/official-omp-gate0.ts", + "typecheck": "tsgo -p tsconfig.json --noEmit" + }, + "dependencies": { + "@agentclientprotocol/sdk": "1.2.1", + "@t4-code/host-wire": "workspace:*", + "chromium-bidi": "12.0.0", + "playwright-core": "1.61.1", + "yaml": "^2.9.0" }, - "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@t4-code/host-wire": "workspace:*", "chromium-bidi": "12.0.0", "playwright-core": "1.61.1" }, "devDependencies": { "@types/bun": "1.3.5", "@types/node": "24.12.4", "typescript": "~6.0.3", "vite-plus": "0.2.2" }, - "engines": { "bun": ">=1.3.14" } + "engines": { + "bun": ">=1.3.14" + } } diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index fd562fff..0d285c93 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -8,6 +8,7 @@ export * from "./omp-authority-bridge-contract.ts"; export * from "./omp-authority-bridge-client.ts"; export * from "./official-omp-capabilities.ts"; export * from "./official-omp-profile-authority.ts"; +export * from "./omp-settings-authority.ts"; export * from "./project-file-search.ts"; export * from "./operations/index.ts"; export * from "./projection.ts"; diff --git a/packages/host-service/src/omp-settings-authority.ts b/packages/host-service/src/omp-settings-authority.ts new file mode 100644 index 00000000..221adde9 --- /dev/null +++ b/packages/host-service/src/omp-settings-authority.ts @@ -0,0 +1,206 @@ +// omp-settings-authority.ts — settings.read/settings.write against the OMP +// profile's real stores on an official host. +// +// Schema (verified against oh-my-pi v17.0.9 sources): +// ~/.omp/config.yml — primary settings store (YAML). modelRoles, +// defaultThinkingLevel, tools.approvalMode, +// enabledModels, disabledProviders, cycleOrder. +// ~/.omp/agent/agent.db — auth_credentials table holds provider keys as +// JSON plaintext in `data` ({key, source}). +// +// Reads mask secrets (sk-…last4); writes replace them wholesale. The file +// revision is a content hash so concurrent writers conflict loudly instead of +// silently last-write-winning. Unknown config.yml keys round-trip untouched — +// omp owns the schema, we only touch the paths listed above. +import { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; +import { open, readFile, rename } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import type { CommandResult } from "@t4-code/host-wire"; +import type { OperationContext } from "./operations/dispatcher.ts"; + +export interface OmpSettingsAuthorityOptions { + /** Directory holding config.yml; defaults to ~/.omp. */ + readonly ompRoot?: string; + /** agent.db path; defaults to /agent/agent.db. */ + readonly agentDbPath?: string; +} + +/** Settings keys this authority exposes/writes. Everything else is left alone. */ +const CONFIG_KEYS = [ + "modelRoles", + "defaultThinkingLevel", + "enabledModels", + "disabledProviders", + "cycleOrder", +] as const; + +function maskSecret(value: unknown): string { + const text = String(value ?? ""); + if (text.length <= 8) return "…"; + return `${text.slice(0, 3)}…${text.slice(-4)}`; +} + +export class OmpSettingsAuthority { + readonly #configPath: string; + readonly #agentDbPath: string; + + constructor(options: OmpSettingsAuthorityOptions = {}) { + const root = options.ompRoot ?? join(homedir(), ".omp"); + this.#configPath = join(root, "config.yml"); + this.#agentDbPath = options.agentDbPath ?? join(root, "agent", "agent.db"); + } + + // ── config.yml ────────────────────────────────────────────────────── + async #readConfig(): Promise<{ doc: Record; revision: string }> { + let text = ""; + try { + text = await readFile(this.#configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const parsed = text ? parseYaml(text) : undefined; + const doc = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + return { doc, revision: createHash("sha256").update(text).digest("hex").slice(0, 16) }; + } + + async #writeConfig(doc: Record): Promise { + const text = stringifyYaml(doc, { indent: 2 }); + const tmp = `${this.#configPath}.tmp`; + const handle = await open(tmp, "w", 0o600); + try { + await handle.writeFile(text, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tmp, this.#configPath); + } + + // ── agent.db auth_credentials ──────────────────────────────────────── + #openDb(): Database { + return new Database(this.#agentDbPath); + } + + #readProviderKeys(): Record { + try { + const db = this.#openDb(); + try { + const rows = db + .prepare("SELECT provider, credential_type, data FROM auth_credentials ORDER BY provider") + .all() as { provider: string; credential_type: string; data: string }[]; + const out: Record = {}; + for (const row of rows) { + let secret: unknown; + try { + const data = JSON.parse(row.data) as Record; + secret = data.key ?? data.access_token; + } catch { + secret = undefined; + } + out[row.provider] = maskSecret(secret); + } + return out; + } finally { + db.close(); + } + } catch { + // A missing/corrupt agent.db means "no keys yet", not a settings failure. + return {}; + } + } + + #writeProviderKey(provider: string, key: string): void { + if (!/^[A-Za-z0-9._-]{1,64}$/u.test(provider)) throw new Error("provider name is invalid"); + const db = this.#openDb(); + try { + const now = Date.now(); + const data = JSON.stringify({ key, source: "settings" }); + // auth_credentials has no unique constraint on (provider, type) — + // upsert by hand. + const existing = db + .prepare("SELECT id FROM auth_credentials WHERE provider = ? AND credential_type = 'api_key'") + .get(provider) as { id: number } | undefined; + if (existing) + db.prepare("UPDATE auth_credentials SET data = ?, updated_at = ? WHERE id = ?").run(data, now, existing.id); + else + db.prepare( + "INSERT INTO auth_credentials (provider, credential_type, data, created_at, updated_at) VALUES (?, 'api_key', ?, ?, ?)", + ).run(provider, data, now, now); + } finally { + db.close(); + } + } + + // ── operations ────────────────────────────────────────────────────── + // The wire's boundedSettings demands every settings value be an object — + // bare scalars die in its JSON-literal parser. So every leaf travels as a + // metadata entry {effective, type}. + async settingsRead(): Promise { + const { doc, revision } = await this.#readConfig(); + const settings: Record = {}; + for (const key of CONFIG_KEYS) { + if (doc[key] === undefined) continue; + settings[key] = { type: Array.isArray(doc[key]) ? "list" : typeof doc[key] === "object" ? "map" : "string", effective: doc[key] }; + } + const tools = doc.tools; + if (tools && typeof tools === "object" && !Array.isArray(tools)) { + const approvalMode = (tools as Record).approvalMode; + if (approvalMode !== undefined) settings["tools.approvalMode"] = { type: "enum", effective: approvalMode, options: ["always-ask", "write", "yolo"] }; + const approval = (tools as Record).approval; + if (approval !== undefined) settings["tools.approval"] = { type: "map", effective: approval }; + } + const keys = this.#readProviderKeys(); + if (Object.keys(keys).length > 0) { + settings.providerKeys = { + type: "map", + effective: Object.fromEntries( + Object.entries(keys).map(([provider, masked]) => [provider, { type: "string", effective: masked }]), + ), + }; + } + return { settings, revision }; + } + + async settingsWrite(args: CommandResult, context: OperationContext): Promise { + const patch = (args.settings ?? args) as Record; + const { doc, revision } = await this.#readConfig(); + // The dispatcher enforces revision-required; the challenge carries our + // current revision, so a concurrent change fails here instead of + // clobbering. + if (typeof context.expectedRevision === "string" && context.expectedRevision !== revision) + throw new Error(`settings revision conflict: expected ${context.expectedRevision}, current ${revision}`); + + const next: Record = { ...doc }; + for (const key of CONFIG_KEYS) { + if (patch[key] !== undefined) next[key] = patch[key]; + } + if (patch["tools.approvalMode"] !== undefined || patch["tools.approval"] !== undefined) { + const tools = { ...(next.tools as Record | undefined) }; + if (patch["tools.approvalMode"] !== undefined) tools.approvalMode = patch["tools.approvalMode"]; + if (patch["tools.approval"] !== undefined) tools.approval = patch["tools.approval"]; + next.tools = tools; + } + const providerKeys = patch.providerKeys; + if (providerKeys && typeof providerKeys === "object" && !Array.isArray(providerKeys)) { + for (const [provider, key] of Object.entries(providerKeys as Record)) { + if (typeof key !== "string" || key.length < 8) throw new Error(`provider key for ${provider} is invalid`); + this.#writeProviderKey(provider, key); + } + } + await this.#writeConfig(next); + const { revision: newRevision } = await this.#readConfig(); + return { written: true, revision: newRevision }; + } + + operations() { + return { + settingsRead: () => this.settingsRead(), + settingsWrite: (args: CommandResult, context: OperationContext) => this.settingsWrite(args, context), + }; + } +} diff --git a/packages/host-service/test/omp-settings-authority.test.ts b/packages/host-service/test/omp-settings-authority.test.ts new file mode 100644 index 00000000..62e84463 --- /dev/null +++ b/packages/host-service/test/omp-settings-authority.test.ts @@ -0,0 +1,118 @@ +import { Database } from "bun:sqlite"; +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { OmpSettingsAuthority } from "../src/omp-settings-authority.ts"; +import type { OperationContext } from "../src/operations/dispatcher.ts"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "t4-omp-settings-")); + const agent = join(root, "agent"); + const databasePath = join(agent, "agent.db"); + await mkdir(agent, { recursive: true }); + await writeFile( + join(root, "config.yml"), + [ + "unknownSetting: preserved", + "defaultThinkingLevel: high", + "modelRoles:", + " default: openai/gpt-5", + "tools:", + " approvalMode: write", + "", + ].join("\n"), + { mode: 0o600 }, + ); + const database = new Database(databasePath); + database.run( + "CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, credential_type TEXT, data TEXT, created_at INTEGER, updated_at INTEGER)", + ); + database + .prepare( + "INSERT INTO auth_credentials (provider, credential_type, data, created_at, updated_at) VALUES (?, 'api_key', ?, 1, 1)", + ) + .run("openai", JSON.stringify({ key: "sk-existing-secret", source: "test" })); + database.close(); + return { + root, + databasePath, + authority: new OmpSettingsAuthority({ ompRoot: root, agentDbPath: databasePath }), + }; +} + +test("official settings reads wire-safe metadata and masks provider secrets", async () => { + const { authority } = await fixture(); + const result = await authority.settingsRead(); + + expect(result.settings).toMatchObject({ + defaultThinkingLevel: { type: "string", effective: "high" }, + modelRoles: { type: "map", effective: { default: "openai/gpt-5" } }, + "tools.approvalMode": { + type: "enum", + effective: "write", + options: ["always-ask", "write", "yolo"], + }, + providerKeys: { + type: "map", + effective: { + openai: { type: "string", effective: "sk-…cret" }, + }, + }, + }); + expect(String(JSON.stringify(result.settings))).not.toContain("sk-existing-secret"); + expect(result.revision).toMatch(/^[a-f0-9]{16}$/u); +}); + +test("official settings writes preserve unknown config and update provider credentials", async () => { + const { authority, databasePath, root } = await fixture(); + const before = await authority.settingsRead(); + const context = { expectedRevision: before.revision } as OperationContext; + const written = await authority.settingsWrite( + { + settings: { + defaultThinkingLevel: "xhigh", + "tools.approvalMode": "always-ask", + providerKeys: { anthropic: "sk-ant-new-secret" }, + }, + }, + context, + ); + + expect(written).toMatchObject({ written: true }); + expect(written.revision).not.toBe(before.revision); + const config = parseYaml(await readFile(join(root, "config.yml"), "utf8")) as Record; + expect(config.unknownSetting).toBe("preserved"); + expect(config.defaultThinkingLevel).toBe("xhigh"); + expect(config.tools).toMatchObject({ approvalMode: "always-ask" }); + + const database = new Database(databasePath, { readonly: true }); + const row = database + .prepare("SELECT data FROM auth_credentials WHERE provider = ? AND credential_type = 'api_key'") + .get("anthropic") as { data: string }; + database.close(); + expect(JSON.parse(row.data)).toEqual({ key: "sk-ant-new-secret", source: "settings" }); + + const after = await authority.settingsRead(); + expect(after.settings).toMatchObject({ + providerKeys: { + effective: { + anthropic: { effective: "sk-…cret" }, + }, + }, + }); +}); + +test("official settings reject stale revisions before mutating config", async () => { + const { authority, root } = await fixture(); + const before = await readFile(join(root, "config.yml"), "utf8"); + + await expect( + authority.settingsWrite( + { settings: { defaultThinkingLevel: "off" } }, + { expectedRevision: "stale-revision" } as OperationContext, + ), + ).rejects.toThrow("settings revision conflict"); + expect(await readFile(join(root, "config.yml"), "utf8")).toBe(before); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbb3f06a..2c235e3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: version: 4.3.0 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) apps/desktop: dependencies: @@ -127,7 +127,7 @@ importers: version: 26.15.6(electron-builder-squirrel-windows@26.15.6) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) apps/mobile: dependencies: @@ -165,13 +165,13 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) apps/web: dependencies: @@ -250,7 +250,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + version: 4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -259,16 +259,16 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.0 - version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + version: 6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) tailwindcss: specifier: ^4.0.0 version: 4.3.2 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 - version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/client: dependencies: @@ -293,7 +293,7 @@ importers: version: 8.5.13 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) ws: specifier: 8.21.0 version: 8.21.0 @@ -318,7 +318,7 @@ importers: version: 6.0.3 vite-plus: specifier: 0.2.2 - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/fixture-server: dependencies: @@ -337,7 +337,7 @@ importers: version: 8.5.13 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/host-daemon: dependencies: @@ -378,6 +378,9 @@ importers: playwright-core: specifier: 1.61.1 version: 1.61.1 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@types/bun': specifier: 1.3.5 @@ -390,7 +393,7 @@ importers: version: 6.0.3 vite-plus: specifier: 0.2.2 - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/host-wire: devDependencies: @@ -405,7 +408,7 @@ importers: version: 6.0.3 vite-plus: specifier: 0.2.2 - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/model-gateway: devDependencies: @@ -420,7 +423,7 @@ importers: version: 6.0.3 vite-plus: specifier: 0.2.2 - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/protocol: dependencies: @@ -436,7 +439,7 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/remote: dependencies: @@ -449,7 +452,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/service-manager: devDependencies: @@ -458,7 +461,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/t4-api-client: dependencies: @@ -474,7 +477,7 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages/t4-api-contract: devDependencies: @@ -520,7 +523,7 @@ importers: version: 19.2.6(react@19.2.6) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) + version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) packages: @@ -3583,6 +3586,11 @@ packages: yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -4405,12 +4413,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': + '@tailwindcss/vite@4.3.2(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' '@tanstack/history@1.162.0': {} @@ -4582,45 +4590,45 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@vitejs/plugin-react@6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': + '@vitejs/plugin-react@6.0.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' - '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10)': + '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) - vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) + vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10)': + '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))) + vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10)': + '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -4628,16 +4636,16 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10)': + '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -4663,37 +4671,37 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))': + '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' - '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': + '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))': + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' - '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))': + '@vitest/mocker@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' '@vitest/pretty-format@4.1.10': dependencies: @@ -4743,7 +4751,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)': + '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 @@ -4754,8 +4762,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 typescript: 6.0.3 + yaml: 2.9.0 - '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)': + '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.138.0 '@oxc-project/types': 0.138.0 @@ -4766,6 +4775,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 typescript: 6.0.3 + yaml: 2.9.0 '@voidzero-dev/vite-plus-darwin-arm64@0.2.2': optional: true @@ -6292,7 +6302,7 @@ snapshots: typescript: 6.0.3 yargs-parser: 21.1.1 - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -6315,9 +6325,9 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -6340,7 +6350,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) + vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) oxlint-tsgolint@0.24.0: optionalDependencies: @@ -6351,7 +6361,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-x64': 0.24.0 - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -6373,9 +6383,9 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + vite-plus: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -6397,7 +6407,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) + vite-plus: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -6987,25 +6997,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3): + vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' - vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' + vitest: 4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -7045,25 +7055,25 @@ snapshots: - utf-8-validate - yaml - vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3): + vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))) '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 - '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' + vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -7103,10 +7113,10 @@ snapshots: - utf-8-validate - yaml - vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): + vitest@4.1.10(@types/node@24.12.4)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -7123,18 +7133,18 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -7151,11 +7161,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)' + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10) transitivePeerDependencies: - msw @@ -7223,6 +7233,8 @@ snapshots: yaml-ast-parser@0.0.43: {} + yaml@2.9.0: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1