Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## [0.13.0] — 2026-07-24

Code-review fixes across both platforms, plus per-provider regenerate on
Windows.

### Added
- Windows: "Regenerate with …" — regenerate the last answer through a
specific provider, from the regenerate menu. macOS has had this since
Epic C3.

### Fixed
- Windows: fix random crashes when background work overlapped with the UI.
The store keeps a single SQLite connection, which is not thread-safe, but
it was used concurrently by the embedding worker, the folder watcher, the
retriever, and every `Task.Run` in the view models. All database access is
now serialized. (macOS was never affected — GRDB's `DatabaseQueue` already
serializes.)
- Windows: an assistant message is now tagged with the model that actually
produced it, instead of the model captured when the chat engine was built.
- Windows: the provider router no longer discards a provider-qualified model,
which is what "Regenerate with …" above needs to reach a chosen provider.
- Windows: a rate-limited provider's `Retry-After` is now honored instead of
retrying after the app's own much shorter backoff.
- Windows: backups are written with SQLite's online-backup API. The previous
code checkpointed a write-ahead log that was never enabled and then copied
the database file, which could capture a partial write.
- Both platforms: attachment reads and writes can no longer escape the
attachments folder. The filename was already reduced to a single path
component; the note-folder segment beside it was not, and it arrives from
the editor's `attachment://` URL — so a crafted link in a note reached one
directory above the attachments root.
- Both platforms: a retried answer no longer renders appended to the partial
text of the attempt that failed.
- Windows: a credential that cannot be removed from Credential Manager now
reports the failure instead of being silently treated as deleted.

## [0.12.2] — 2026-07-19

Windows fixes.
Expand Down
28 changes: 20 additions & 8 deletions Sources/AINotebookApp/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,16 @@ struct ChatView: View {
sessionId: sid,
notebookId: notebook.id!,
sourceIds: effectiveSourceIds,
model: model
) { token in
Task { @MainActor in streamingDraft += token }
}
model: model,
onToken: { token in
Task { @MainActor in streamingDraft += token }
},
onRetry: {
// Drop the failed attempt's partial text so the retry does
// not render appended to it.
Task { @MainActor in streamingDraft = "" }
}
)
await reloadMessages()
} catch {
errorMessage = providerErrorText(error, text: settings.text)
Expand Down Expand Up @@ -547,10 +553,16 @@ struct ChatView: View {
sourceIds: effectiveSourceIds,
useWebSearch: settings.webSearchEnabled && useWebForNextMessage,
model: activePersona?.model,
instructionsOverride: personaInstructions
) { token in
Task { @MainActor in streamingDraft += token }
}
instructionsOverride: personaInstructions,
onToken: { token in
Task { @MainActor in streamingDraft += token }
},
onRetry: {
// Drop the failed attempt's partial text so the retry does
// not render appended to it.
Task { @MainActor in streamingDraft = "" }
}
)
await reloadMessages()
await generateFollowups(userText: text, answer: reply.content)
} catch {
Expand Down
2 changes: 1 addition & 1 deletion Sources/AINotebookCore/AINotebookVersion.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Non-isolated top-level constant so it can be read from any actor context.
// Must equal the repo-root VERSION file — AINotebookVersionTests enforces it.
// Surfaced in Settings and used by the update checker.
public let AINotebookVersion = "0.12.2"
public let AINotebookVersion = "0.13.0"
29 changes: 26 additions & 3 deletions Sources/AINotebookCore/AttachmentStore.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import Foundation
import GRDB

public enum AttachmentStoreError: Error, Equatable, Sendable {
/// The note-folder segment reduced to a traversal component (`..`, `.`, or
/// empty) instead of a note UUID.
case invalidNoteFolder(String)
}

@MainActor
public final class AttachmentStore {

Expand Down Expand Up @@ -32,7 +38,7 @@ public final class AttachmentStore {
mime: String,
bytes: Data
) throws -> NoteAttachment {
let folder = root.appendingPathComponent(noteUuid, isDirectory: true)
let folder = root.appendingPathComponent(try Self.safeSegment(noteUuid), isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let resolved = uniqueFilename(in: folder, requested: Self.safeFilename(filename))
let url = folder.appendingPathComponent(resolved)
Expand All @@ -52,7 +58,7 @@ public final class AttachmentStore {

public func read(noteUuid: String, filename: String) throws -> Data {
let url = root
.appendingPathComponent(noteUuid, isDirectory: true)
.appendingPathComponent(try Self.safeSegment(noteUuid), isDirectory: true)
.appendingPathComponent(Self.safeFilename(filename))
return try Data(contentsOf: url)
}
Expand All @@ -66,6 +72,23 @@ public final class AttachmentStore {
return base
}

/// The same reduction for the note-folder segment, which used to be joined
/// onto `root` raw — so a `..` reaching this far escaped the attachments
/// root by one level, into the directory holding the database. The scheme
/// handler hands us the URL host, so the value is not trusted.
///
/// Unlike `safeFilename` this throws instead of substituting a default:
/// every legitimate caller passes a real note UUID, so anything reducing to
/// a traversal segment is a bug or an attack, and failing closed is the
/// only correct answer.
static func safeSegment(_ requested: String) throws -> String {
let base = (requested as NSString).lastPathComponent
guard !base.isEmpty, base != ".", base != ".." else {
throw AttachmentStoreError.invalidNoteFolder(requested)
}
return base
}

public func list(noteId: Int64) throws -> [NoteAttachment] {
try store.runOnDatabase { db in
try NoteAttachment
Expand All @@ -76,7 +99,7 @@ public final class AttachmentStore {
}

public func deleteFolder(noteUuid: String) throws {
let folder = root.appendingPathComponent(noteUuid, isDirectory: true)
let folder = root.appendingPathComponent(try Self.safeSegment(noteUuid), isDirectory: true)
if FileManager.default.fileExists(atPath: folder.path) {
try FileManager.default.removeItem(at: folder)
}
Expand Down
20 changes: 15 additions & 5 deletions Sources/AINotebookCore/ChatEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ public actor ChatEngine {
useWebSearch: Bool = false,
model: String? = nil,
instructionsOverride: String? = nil,
onToken: @escaping @Sendable (String) -> Void
onToken: @escaping @Sendable (String) -> Void,
onRetry: @escaping @Sendable () -> Void = {}
) async throws -> ChatMessage {
// 1) Persist the user message.
let storeRef = store
Expand All @@ -74,7 +75,8 @@ public actor ChatEngine {
sourceIds: sourceIds,
useWebSearch: useWebSearch,
instructionsOverride: instructionsOverride,
onToken: onToken
onToken: onToken,
onRetry: onRetry
)
}

Expand All @@ -88,7 +90,8 @@ public actor ChatEngine {
currentNoteContent: String? = nil,
sourceIds: Set<Int64> = [],
model: String? = nil,
onToken: @escaping @Sendable (String) -> Void
onToken: @escaping @Sendable (String) -> Void,
onRetry: @escaping @Sendable () -> Void = {}
) async throws -> ChatMessage {
let storeRef = store
let history = try await MainActor.run { try storeRef.messages(sessionId: sessionId) }
Expand All @@ -108,7 +111,8 @@ public actor ChatEngine {
model: model ?? chatModel,
currentNoteContent: currentNoteContent,
sourceIds: sourceIds,
onToken: onToken
onToken: onToken,
onRetry: onRetry
)
}

Expand All @@ -125,7 +129,8 @@ public actor ChatEngine {
sourceIds: Set<Int64>,
useWebSearch: Bool = false,
instructionsOverride: String? = nil,
onToken: @escaping @Sendable (String) -> Void
onToken: @escaping @Sendable (String) -> Void,
onRetry: @escaping @Sendable () -> Void = {}
) async throws -> ChatMessage {
let storeRef = store
// 2) Retrieve context.
Expand Down Expand Up @@ -188,6 +193,10 @@ public actor ChatEngine {
case .rateLimit(let retryAfterSeconds):
if attempt >= retryAttempts { throw providerError }
attempt += 1
// Tokens from the failed attempt already reached
// onToken, so the caller's buffer holds a partial
// answer this retry would append to. Tell it to drop.
onRetry()
let fallback = Double(retryBackoffMillis) * pow(2.0, Double(attempt - 1)) / 1000.0
let seconds = retryAfterSeconds ?? fallback
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
Expand All @@ -198,6 +207,7 @@ public actor ChatEngine {
}
if attempt >= retryAttempts { throw error }
attempt += 1
onRetry()
let delayNs = UInt64(retryBackoffMillis * Int(pow(2.0, Double(attempt - 1)))) * 1_000_000
try? await Task.sleep(nanoseconds: delayNs)
}
Expand Down
88 changes: 88 additions & 0 deletions Tests/AINotebookCoreTests/AttachmentStoreTraversalTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import XCTest
@testable import AINotebookCore

/// The attachment filename has always been reduced to one path component, but
/// the note-folder segment beside it was joined onto the root raw. The scheme
/// handler passes the URL host straight in, so a crafted `attachment://../x`
/// image in a note resolved one level above the attachments root — the
/// directory holding the database. Windows has the same test in
/// AttachmentStoreTraversalTests.cs.
@MainActor
final class AttachmentStoreTraversalTests: XCTestCase {

private func tempRoot() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("aino-att-trav-\(UUID().uuidString)")
}

private func fixture() throws -> (atts: AttachmentStore, root: URL, noteId: Int64) {
let store = try NotebookStore(path: .inMemory)
let nb = try store.createNotebook(name: "NB")
// attachments.note_id is a real FK, so the row needs a real note.
let note = try store.createNote(notebookId: nb.id!, title: "T", bodyMd: "")
let root = tempRoot()
return (AttachmentStore(store: store, root: root), root, note.id!)
}

/// Asserts the SPECIFIC rejection, not merely "it threw". A plain
/// XCTAssertThrowsError passes even with the fix removed, because reading
/// a path that happens not to exist throws too — the escape has to be
/// distinguished from a miss, hence the real file planted outside the root.
private func assertRejects(
_ label: String, _ body: () throws -> Void,
file: StaticString = #filePath, line: UInt = #line
) {
XCTAssertThrowsError(try body(), label, file: file, line: line) { error in
guard let e = error as? AttachmentStoreError, case .invalidNoteFolder = e else {
return XCTFail("\(label): expected .invalidNoteFolder, got \(error)",
file: file, line: line)
}
}
}

func testTraversalNoteFolderIsRejected() throws {
let (atts, root, noteId) = try fixture()
defer { try? FileManager.default.removeItem(at: root) }

// A REAL file one level above the attachments root — what the old code
// handed back to the WebView. Without this the read would fail as
// "no such file" and mask a missing guard.
let escaped = root.deletingLastPathComponent().appendingPathComponent("aino-escaped.bin")
try Data([9, 9, 9]).write(to: escaped)
defer { try? FileManager.default.removeItem(at: escaped) }

for uuid in ["..", ".", "", "../..", "foo/.."] {
let shown = uuid.isEmpty ? "<empty>" : uuid
assertRejects("read \(shown)") {
_ = try atts.read(noteUuid: uuid, filename: "aino-escaped.bin")
}
assertRejects("deleteFolder \(shown)") { try atts.deleteFolder(noteUuid: uuid) }
assertRejects("save \(shown)") {
_ = try atts.save(noteId: noteId, noteUuid: uuid,
filename: "planted.bin", mime: "image/png",
bytes: Data([1]))
}
}

// The guard held: the outside file is untouched and no new one appeared
// beside it.
XCTAssertEqual(try Data(contentsOf: escaped), Data([9, 9, 9]))
let planted = root.deletingLastPathComponent().appendingPathComponent("planted.bin")
XCTAssertFalse(FileManager.default.fileExists(atPath: planted.path),
"traversal write escaped the attachments root")
}

func testRoundTripsInsideTheNoteFolder() throws {
let (atts, root, noteId) = try fixture()
defer { try? FileManager.default.removeItem(at: root) }

let uuid = UUID().uuidString.lowercased()
let bytes = Data([1, 2, 3])
let att = try atts.save(noteId: noteId, noteUuid: uuid,
filename: "shot.png", mime: "image/png", bytes: bytes)

XCTAssertEqual(try atts.read(noteUuid: uuid, filename: att.filename), bytes)
let onDisk = root.appendingPathComponent(uuid).appendingPathComponent(att.filename)
XCTAssertTrue(FileManager.default.fileExists(atPath: onDisk.path))
}
}
54 changes: 54 additions & 0 deletions Tests/AINotebookCoreTests/ChatEngineRetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,60 @@ final class ChatEngineRetryTests: XCTestCase {
XCTAssertEqual(chat.attempts, 1)
}

/// Tokens from a failed attempt have already reached `onToken`, so the
/// caller's buffer holds a partial answer the retry would append to. The
/// engine must tell it to discard. Windows parity:
/// ChatEngineModelAndRetryTests.OnRetryFiresOncePerFailedAttempt.
func testOnRetryFiresOncePerFailedAttempt() async throws {
let (store, sessionId, notebookId) = try makeChatFixture()
let chat = FlakyChat(failuresRemaining: 2, tokens: ["ok"])
let engine = makeEngine(store: store, chat: chat)

// A stand-in for the view's streamingDraft: appended per token, cleared
// on retry. Without the clear it would read "partialpartialok".
actor Draft {
private(set) var text = ""
private(set) var clears = 0
func append(_ t: String) { text += t }
func clear() { text = ""; clears += 1 }
}
let draft = Draft()

_ = try await engine.send(
sessionId: sessionId, notebookId: notebookId, userText: "hi",
onToken: { t in Task { await draft.append(t) } },
onRetry: { Task { await draft.clear() } }
)

XCTAssertEqual(chat.attempts, 3, "initial + 2 retries")
// Let the detached Tasks above settle before reading.
try await Task.sleep(nanoseconds: 50_000_000)
let clears = await draft.clears
XCTAssertEqual(clears, 2, "one clear per failed attempt")
}

func testOnRetryIsNotFiredWhenTheFirstAttemptSucceeds() async throws {
let (store, sessionId, notebookId) = try makeChatFixture()
let chat = FlakyChat(failuresRemaining: 0, tokens: ["ok"])
let engine = makeEngine(store: store, chat: chat)

let counter = Counter()
_ = try await engine.send(
sessionId: sessionId, notebookId: notebookId, userText: "hi",
onToken: { _ in },
onRetry: { Task { await counter.bump() } }
)

try await Task.sleep(nanoseconds: 50_000_000)
let n = await counter.value
XCTAssertEqual(n, 0)
}

actor Counter {
private(set) var value = 0
func bump() { value += 1 }
}

func testRateLimitRetriesWithServerHint() async throws {
let (store, sessionId, notebookId) = try makeChatFixture()
let chat = ThrowingChat(error: ProviderError.rateLimit(retryAfterSeconds: 0.01))
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.12.2
0.13.0
Loading
Loading