Skip to content

File Provider: add bounded SFTP read transport - #32

Open
obra wants to merge 5 commits into
h3nock:mainfrom
obra:codex/file-provider-review/read-transport
Open

File Provider: add bounded SFTP read transport#32
obra wants to merge 5 commits into
h3nock:mainfrom
obra:codex/file-provider-review/read-transport

Conversation

@obra

@obra obra commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Reviewability experiment

This is an attempt to make the File Provider work more reviewable by presenting the existing atomic work as a small, coherent stack. If this shape or boundary does not feel right, please say so instead of spending effort reviewing an unhelpful presentation—we can reshape it.

This is a history/presentation rewrite of the already-tested implementation. It does not add new product behavior.

Intended review slice

  • Incremental parent: codex/file-provider-review/domain-foundation
  • Branch: codex/file-provider-review/read-transport
  • Production delta: 479 additions / 129 deletions (F4–F5)

The commits in this branch are the atomic commits intended for this slice:

de550b4 sftp: add bounded listing and download operations
d361032 ssh: isolate startup tracing from extension builds

Stack mechanics

All PRs in this stack intentionally target main. The branches themselves remain sequential, so GitHub may show earlier ancestry in a later PR until its parents merge. Please review the commit range above as the intended slice and merge these PRs in order; after a parent lands, GitHub will reduce the next PR diff to its incremental changes.

Verification

The final rewritten leaf was verified with 1164 tests passed, 0 failed, and 1 skipped. xcodegen generate produced no project diff, and the final tree is identical to the previously tested simplified result.

Summary by CodeRabbit

  • New Features

    • Added File Provider support for accessing remote servers through the system file browser.
    • Added shared storage and credential migration for reliable app and extension access.
    • Added read-only SFTP directory browsing, file downloads, progress reporting, cancellation, and timeout handling.
    • Added transport startup latency tracing.
  • Bug Fixes

    • Host-key trust is now renewed when a server hostname changes.
    • Improved missing-file handling and cleanup of incomplete downloads.

obra added 5 commits August 1, 2026 15:29
Configure Remux with the accepted shared App Group root and explicit application and shared Keychain access groups. Keep the credential service identity centralized while allowing the existing Keychain store to target either access group through the accepted structured query path.

Preserve the exact accepted F1 test boundary: shared-root resolution, explicit access-group query construction, and shared configuration lookup. The focused suite first failed at the expected missing symbols, then passed 3 tests with 0 failures and 0 skips. A Remux simulator build also succeeded.

Generate the app entitlement, plist values, and Xcode project membership from the accepted configuration with XcodeGen 2.44.1; consecutive generations were deterministic. Shared-state migration, live application/shared Keychain separation, and all File Provider extension wiring remain deferred to F2 and later branches.
Copy profiles, credentials, and hostname-bound trust into the shared container without deleting application-local source state. Verify copied credentials and repository/trust snapshots before atomically writing the migration marker, so failed attempts remain retryable and completed attempts are idempotent.

Expose only the application/shared Keychain store factory at this layer. Migration construction, authoritative shared-repository selection, and lifecycle activation remain intentionally absent for the final app-integration layer.

Preserve the SSH setup rollback-only trust APIs while adding bulk migration access and binding accepted trust to both server identity and hostname.

Verification: accepted tests produced the expected missing-helper compile RED; the focused migration, shared-storage, and trusted-host suites passed 11 tests with 0 failures and 0 skips; the Remux simulator build succeeded. XcodeGen 2.44.1 was deterministic, runtime production delta is 124 lines, and all complete blobs/hunks match their accepted provenance.
Transplant the accepted inactive File Provider domain model exactly as reviewed. Eligible records require a saved SSH credential and trusted host identity matching both server ID and hostname. UUID-derived domain identifiers remain stable, display-name changes reconcile as remove/add pairs, and a FIFO actor gate serializes concurrent callers.

Keep this leaf deliberately unreachable: it links FileProvider.framework into the app target but adds no dependency factory, live instance, RootModel or lifecycle invocation, host-mutation hook, extension target, or compatibility path. Registry and storage failures propagate after the gate is released; no new retry or cleanup behavior is introduced.

The accepted three-test blob covers eligibility, deterministic add/rename/remove behavior with concurrent reconciliation serialized to one mutation, and host-change removal until replacement hostname trust. The tests-first build failed at the expected missing F3 types. The final focused suite passed 3/3 with no failures or skips, and the Remux iPhone 17 simulator build succeeded. XcodeGen 2.44.1 generated the same PBX blob twice.
Move transport startup tracing into a dedicated source that supplies the accepted no-op implementation when REMUX_FILE_PROVIDER_EXTENSION is active. Keep Ghostty tracing out of extension-conditioned SSH cleanup and SFTP setup, and expose root-key construction from the server and resolved-auth pair needed by extension callers.

Regenerate deterministic app-target membership for the new source with XcodeGen 2.44.1. The existing SSH transport and exec selectors pass 74/74 and the normal iPhone 17 simulator build succeeds through XcodeBuildMCP with the required compiler wrappers. No test file changes are included. Global conditioned CLI builds remain a separate target-local extension gate because the setting propagates into SwiftPM dependencies and fails inside Crypto before Remux is compiled.
Define structured SFTP file type, metadata, directory-entry, readable-file, and read-only client values for listings and downloads.

Bound downloads to monotonic chunks and ensure success, timeout, and cancellation close child handles and clean partial files. Reuse the existing connection lease and operation-timeout boundaries.

Evidence: TerminalPreviewFileLoaderTests passed 7/7 at baseline; the focused suites retained the expected missing-read-layer compile failure before production and then passed 15/15; the normal iPhone 17 simulator build passed. XcodeGen 2.44.1 produced deterministic test membership.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The project adds File Provider shared storage, migration, domain reconciliation, expanded read-only SFTP operations, transport startup tracing, entitlements, Keychain access groups, and related tests.

Changes

File Provider and SFTP integration

Layer / File(s) Summary
Shared storage and migration
RemuxApp/Sources/Persistence/..., RemuxApp/Sources/App/RemuxAppDependencies.swift, RemuxApp/Remux.entitlements, RemuxApp/Info.plist, RemuxAppTests/FileProviderSharedStorage*, RemuxAppTests/GhosttyTerminalDisconnectReasonClassifierTests.swift
Adds shared container resolution, separate Keychain stores, credential query access groups, atomic storage migration, and stricter host-and-key trust validation.
File Provider domain reconciliation
RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift, RemuxAppTests/FileProviderDomainReconcilerTests.swift
Adds eligibility-based domain registration, stale-domain removal, async registry operations, serialized reconciliation, and concurrency tests.
SFTP read-only and adapter flow
RemuxApp/Sources/SSH/RemuxSFTPClient.swift, RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift, RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift, RemuxAppTests/TerminalPreviewFileLoaderTests.swift
Adds typed file metadata, directory and symlink operations, Sendable SFTP adapters, atomic chunked downloads, progress, cancellation, timeout handling, and partial-file cleanup.
Transport startup tracing
RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift, RemuxApp/Sources/SSH/RemuxSSHRootService.swift, RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift, RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift
Moves transport startup tracing into its own type, keeps runtime tracing for app builds, and excludes selected tracing paths from File Provider builds.
Xcode project integration
project.yml, Remux.xcodeproj/project.pbxproj
Links FileProvider.framework, adds entitlements and Keychain configuration, and registers the new source and test files.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant FileProviderSharedStorageMigrator
  participant SharedStorage
  participant SSHCredentialStore
  participant FileProviderDomainReconciler
  participant NSFileProviderManager

  App->>FileProviderSharedStorageMigrator: migrate legacy state
  FileProviderSharedStorageMigrator->>SharedStorage: copy profiles and trusted hosts
  FileProviderSharedStorageMigrator->>SSHCredentialStore: copy and verify credentials
  FileProviderSharedStorageMigrator-->>App: write completion marker
  App->>FileProviderDomainReconciler: reconcile domains
  FileProviderDomainReconciler->>NSFileProviderManager: remove stale domains
  FileProviderDomainReconciler->>NSFileProviderManager: add eligible domains
Loading

Possibly related PRs

  • h3nock/remux#8: Both changes modify shared SSH dependency and trusted-host infrastructure.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: adding bounded SFTP read transport for the File Provider stack.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de550b4b17

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +53 to +54
if let credential = try await legacyCredentials.loadCredential(identityID: identity.id) {
try await sharedCredentials.saveCredential(credential, identityID: identity.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Delete stale shared credentials when the source is empty

When a migration attempt saves a credential but fails before writing the marker, a later retry after that legacy credential has been deleted skips this block and leaves the copied secret in the shared keychain. Because the final verification checks only profiles and trust, the retry can then mark migration complete, and FileProviderDomainReconciler will treat the server as eligible using a credential the user removed. Delete any destination credential when the legacy lookup returns nil, or verify credential absence before writing the marker.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (9)
RemuxApp/Sources/Persistence/TrustedHostStore.swift (1)

88-92: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Document that replaceIdentities bypasses host-key validation.

replaceIdentities writes the trust file directly. It skips the challenge and trust-transition checks that trustHostKey enforces. restoreIdentity above carries a warning comment for the same reason. Add the equivalent comment so a later caller does not use this method to establish new trust.

♻️ Proposed change
+    /// Replaces the whole stored identity collection.
+    ///
+    /// This intentionally bypasses host-key challenge and trust-transition
+    /// validation. Use it only for migration and rollback. Callers must not
+    /// use it to establish new trust.
     func replaceIdentities(_ identities: [TrustedHostIdentity]) throws {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/Persistence/TrustedHostStore.swift` around lines 88 - 92,
Add a warning comment immediately above replaceIdentities(_:) documenting that
it writes identities directly and bypasses the host-key challenge and
trust-transition validation enforced by trustHostKey, so callers do not use it
to establish new trust. Match the warning style used by restoreIdentity.
RemuxApp/Sources/Persistence/SSHCredentialStore.swift (1)

166-188: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Set kSecAttrAccessible explicitly for shared credentials.

The query does not set kSecAttrAccessible, so added items use the default kSecAttrAccessibleWhenUnlocked. The File Provider extension can be started while the device is locked. The credential read then fails with errSecInteractionNotAllowed, and domain reconciliation treats the server as ineligible.

Set the accessibility on add. kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly matches this use case and keeps the item off backups.

🔒️ Proposed change
     static func query(
         service: String,
         accessGroup: String?,
         identityID: SSHIdentity.ID,
         returnData: Bool
     ) -> [CFString: Any] {
         var query: [CFString: Any] = [
             kSecClass: kSecClassGenericPassword,
             kSecAttrService: service,
             kSecAttrAccount: identityID.uuidString,
         ]

Then add the attribute in saveCredential on the add path only:

             var addQuery = query
             addQuery[kSecValueData] = data
+            addQuery[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
             let addStatus = SecItemAdd(addQuery as CFDictionary, nil)

Note: kSecAttrAccessible must not be part of the SecItemUpdate match query, so keep it out of query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/Persistence/SSHCredentialStore.swift` around lines 166 -
188, Update saveCredential’s add-item attributes to explicitly set
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, while
leaving the shared query(_:accessGroup:identityID:returnData:) unchanged so the
attribute is not used in SecItemUpdate matching.
RemuxApp/Sources/Persistence/ApplicationStorage.swift (1)

57-71: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

sharedRemuxRoot ignores the fileManager parameter and does not create the directory.

Two points on this API:

  1. The fileManager parameter is never used. The default containerURL closure calls FileManager.default directly. A caller that injects a different FileManager gets no effect, which is misleading.
  2. remuxRoot creates the directory before returning. sharedRemuxRoot does not. Callers must create the directory themselves. Make the asymmetry explicit or align the two functions.
♻️ Proposed refactor to use the injected `FileManager`
     static func sharedRemuxRoot(
         appGroupIdentifier: String = FileProviderSharedConfiguration.appGroupIdentifier,
         fileManager: FileManager = .default,
-        containerURL: `@Sendable` (String) -> URL? = {
-            FileManager.default.containerURL(
-                forSecurityApplicationGroupIdentifier: $0
-            )
-        }
+        containerURL: (`@Sendable` (FileManager, String) -> URL?)? = nil
     ) throws -> URL {
-        guard let containerURL = containerURL(appGroupIdentifier) else {
+        let resolve = containerURL ?? { manager, identifier in
+            manager.containerURL(forSecurityApplicationGroupIdentifier: identifier)
+        }
+        guard let containerURL = resolve(fileManager, appGroupIdentifier) else {
             throw FileProviderSharedConfigurationError.missingSharedContainer
         }
 
         return containerURL.appendingPathComponent("Remux", isDirectory: true)
     }

Keep the signature if you prefer, but then remove the unused fileManager parameter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/Persistence/ApplicationStorage.swift` around lines 57 - 71,
Update sharedRemuxRoot so its default containerURL closure uses the injected
fileManager rather than FileManager.default, and create the returned Remux
directory before returning it to match remuxRoot. Preserve the existing
missingSharedContainer error behavior and method signature.
RemuxAppTests/FileProviderDomainReconcilerTests.swift (1)

230-237: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Decrement activeMutationCount with defer.

Task.sleep throws on cancellation. activeMutationCount -= 1 on line 236 is then skipped, and maximumConcurrentMutationCount reports a value that overstates the real concurrency. Use defer so the counter always unwinds.

♻️ Proposed change
     private func mutate(_ mutation: Mutation, body: () -> Void) async throws {
         activeMutationCount += 1
         maximumConcurrentMutationCount = max(maximumConcurrentMutationCount, activeMutationCount)
+        defer { activeMutationCount -= 1 }
         try await Task.sleep(for: .milliseconds(10))
         body()
         mutations.append(mutation)
-        activeMutationCount -= 1
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxAppTests/FileProviderDomainReconcilerTests.swift` around lines 230 -
237, Update the async mutate method to register a defer immediately after
incrementing activeMutationCount, ensuring the counter is decremented whenever
the method exits, including when Task.sleep throws due to cancellation. Remove
the direct decrement at the end while preserving the existing mutation and
maximum-concurrency updates.
RemuxAppTests/FileProviderSharedStorageMigratorTests.swift (1)

212-221: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Let the helper inspect the thrown error.

The helper accepts any error. testMigrationFailureLeavesSourceIntactDoesNotMarkAndRetriesIdempotently therefore passes even if the migration fails for an unintended reason, such as verificationFailed. Add an error handler so the caller can assert the specific case.

♻️ Proposed change
 private func XCTAssertThrowsErrorAsync(
     _ expression: () async throws -> Void,
     file: StaticString = `#filePath`,
-    line: UInt = `#line`
+    line: UInt = `#line`,
+    _ errorHandler: (Error) -> Void = { _ in }
 ) async {
     do {
         try await expression()
         XCTFail("expected error", file: file, line: line)
-    } catch {}
+    } catch {
+        errorHandler(error)
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxAppTests/FileProviderSharedStorageMigratorTests.swift` around lines 212
- 221, Update XCTAssertThrowsErrorAsync to accept an error-handling closure and
invoke it with the caught error, while preserving the existing XCTFail behavior
when no error is thrown. Update
testMigrationFailureLeavesSourceIntactDoesNotMarkAndRetriesIdempotently to use
the handler and assert the expected migration failure rather than accepting
unrelated errors.
RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift (1)

132-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse one identifier rule for add and remove.

add builds the identifier from record.rawIdentifier. remove rebuilds serverID.uuidString.lowercased() inline. The two encode the same rule in two places. If the rule changes in one place, remove targets an identifier that no longer exists, and the stale domain stays registered.

Move the rule onto the identifier type.

♻️ Proposed change
+extension NSFileProviderDomainIdentifier {
+    fileprivate init(serverID: SavedServer.ID) {
+        self.init(rawValue: serverID.uuidString.lowercased())
+    }
+}

Then use it at both sites:

     func remove(serverID: SavedServer.ID) async throws {
         let domain = NSFileProviderDomain(
-            identifier: NSFileProviderDomainIdentifier(rawValue: serverID.uuidString.lowercased()),
+            identifier: NSFileProviderDomainIdentifier(serverID: serverID),
             displayName: ""
         )

The class also holds no mutable state. Plain Sendable is enough; @unchecked is not required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift` around
lines 132 - 136, Move the shared domain-identifier construction rule onto the
identifier type used by SavedServer, then update both add and remove in
FileProviderDomainReconciler to use that shared API instead of rebuilding the
UUID string inline. Since the reconciler has no mutable state, replace any
unnecessary `@unchecked` Sendable conformance with plain Sendable.
RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift (1)

214-226: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cancel the task before the readiness assertion.

If the polling loop times out on a loaded machine, XCTAssertEqual(readCount, 2) fails and task.cancel() never runs. The test then blocks on task.value for the full 30-second sleep. Register the cancellation with defer right after the task starts.

💚 Proposed change
         let task = Task {
             try await client.downloadFile(atPath: "/cancel", to: destination) { _ in }
         }
+        defer { task.cancel() }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift` around lines 214 - 226,
Register task cancellation with defer immediately after the download task is
created in the test, before the polling loop and readiness assertion. Keep the
existing cancellation call if needed only to preserve behavior without
duplication, and ensure task.value cannot block for the full sleep when the
assertion fails.
RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift (2)

184-197: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

linkMetadata lists the whole parent directory for one entry.

Each call performs a full remote directory listing. If a caller resolves link metadata for every entry of a directory, the remote work becomes quadratic in the entry count. If the File Provider enumerator needs per-entry link metadata, reuse one listing for the whole directory instead of calling linkMetadata per entry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift` around lines 184 - 197,
The linkMetadata(atPath:) implementation performs a full parent-directory
listing for each entry, causing repeated remote work. Update the surrounding
directory enumeration flow to fetch listDirectory(atPath:) once per directory
and reuse the resulting entries’ metadata for all links, rather than invoking
linkMetadata(atPath:) separately for each entry; preserve noSuchFile handling
for missing entries.

630-651: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider routing these traces through the extension-safe shim.

RemuxTransportStartupTrace already compiles to a no-op in the File Provider build. If the SFTP open timing used trace.stage("sftp.open"), these #if !REMUX_FILE_PROVIDER_EXTENSION blocks would not be needed, and the app build would keep the same timing output. The same pattern repeats at lines 771-794.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift` around lines 630 - 651,
Replace the conditional GhosttyRuntimeTrace timing blocks around SFTPClient.open
and the repeated SFTP-open timing block near the later occurrence with
RemuxTransportStartupTrace.trace.stage("sftp.open"). Preserve the existing
begin/end timing output in app builds while relying on the shim’s no-op behavior
for File Provider builds, and remove the surrounding `#if`
!REMUX_FILE_PROVIDER_EXTENSION guards.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@RemuxApp/Remux.entitlements`:
- Around line 9-13: Update the keychain-access-groups entries in the
entitlements configuration to confirm the bundle identifier; if it is not
dev.remux.app, make $(AppIdentifierPrefix)$(CFBundleIdentifier) the first entry
so legacy application-identifier keychain items remain readable during
migration.

In `@RemuxApp/Sources/App/RemuxAppDependencies.swift`:
- Around line 156-169: Correct the access-group mapping used to construct the
migration source in the dependency factory: ensure the application
KeychainSSHCredentialStore uses the application-identifier group that contains
legacy credentials, while shared continues using
FileProviderKeychainAccessGroup. Verify the corresponding
FileProviderSharedConfiguration symbols and keep the migration destination
unchanged.

In `@RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift`:
- Around line 42-60: The reconcile() coalescing logic can lose changes arriving
while a pass is in flight. Add actor state to record a pending reconciliation
request; when reconcile() is called during an existing reconciliationTask, mark
it pending and await the current task, then ensure reconcileDomains() runs one
additional pass before clearing the task when pending is set. Update
testReconcileAddsRenamesAndRemovesToMatchDesiredSet to expect the additional
reconciliation request.

In `@RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift`:
- Around line 70-75: Update the migration verification in migrateIfNeeded to
validate that the shared snapshot contains all legacy servers and workspaces
rather than requiring exact snapshot equality. Change the sharedTrust update
from replaceIdentities to merging the legacy trusted identities with existing
shared identities, while preserving existing entries and verifying the merged
result.

In `@RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift`:
- Around line 151-155: Update realPath(atPath:) to catch Citadel’s missing-path
status and convert it to RemuxSFTPClientError.noSuchFile(path), matching
listDirectory, metadata, and withFile; rethrow all other errors unchanged while
preserving the existing operation timeout behavior.

In `@RemuxApp/Sources/SSH/RemuxSFTPClient.swift`:
- Around line 130-149: Bound the download loop in the SFTP transfer flow around
remoteFile.readChunk by obtaining the remote size from metadata(atPath:) or
enforcing an explicit maximum byte budget. Limit each read and stop once the
budget or reported file size is reached, while preserving cancellation checks,
local writes, offset updates, progress reporting, and EOF handling.

In `@RemuxAppTests/FileProviderDomainReconcilerTests.swift`:
- Around line 119-138: Replace the KeychainSSHCredentialStore used by the
fixture setup with the shared in-memory SSHCredentialStore helper, promoting
InMemorySSHCredentialStore from FileProviderSharedStorageMigratorTests.swift if
necessary. Keep the existing credential-saving behavior and ensure the
reconciler continues receiving it through the any SSHCredentialStore interface,
without changing production code.

In `@RemuxAppTests/FileProviderSharedStorageTests.swift`:
- Around line 37-53: In
testLiveCredentialStoresKeepApplicationSourceSeparateFromSharedDestination,
register an addTeardownBlock immediately after creating stores to delete the
application and shared credentials for the generated identityID on every exit
path, then remove the trailing happy-path delete calls.

In `@RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift`:
- Around line 286-289: Update readData(from:length:) to return empty Data when
chunks is exhausted instead of calling removeFirst() on an empty array, while
preserving request recording and normal chunk consumption so requests() detects
unexpected reads.

---

Nitpick comments:
In `@RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift`:
- Around line 132-136: Move the shared domain-identifier construction rule onto
the identifier type used by SavedServer, then update both add and remove in
FileProviderDomainReconciler to use that shared API instead of rebuilding the
UUID string inline. Since the reconciler has no mutable state, replace any
unnecessary `@unchecked` Sendable conformance with plain Sendable.

In `@RemuxApp/Sources/Persistence/ApplicationStorage.swift`:
- Around line 57-71: Update sharedRemuxRoot so its default containerURL closure
uses the injected fileManager rather than FileManager.default, and create the
returned Remux directory before returning it to match remuxRoot. Preserve the
existing missingSharedContainer error behavior and method signature.

In `@RemuxApp/Sources/Persistence/SSHCredentialStore.swift`:
- Around line 166-188: Update saveCredential’s add-item attributes to explicitly
set kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
while leaving the shared query(_:accessGroup:identityID:returnData:) unchanged
so the attribute is not used in SecItemUpdate matching.

In `@RemuxApp/Sources/Persistence/TrustedHostStore.swift`:
- Around line 88-92: Add a warning comment immediately above
replaceIdentities(_:) documenting that it writes identities directly and
bypasses the host-key challenge and trust-transition validation enforced by
trustHostKey, so callers do not use it to establish new trust. Match the warning
style used by restoreIdentity.

In `@RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift`:
- Around line 184-197: The linkMetadata(atPath:) implementation performs a full
parent-directory listing for each entry, causing repeated remote work. Update
the surrounding directory enumeration flow to fetch listDirectory(atPath:) once
per directory and reuse the resulting entries’ metadata for all links, rather
than invoking linkMetadata(atPath:) separately for each entry; preserve
noSuchFile handling for missing entries.
- Around line 630-651: Replace the conditional GhosttyRuntimeTrace timing blocks
around SFTPClient.open and the repeated SFTP-open timing block near the later
occurrence with RemuxTransportStartupTrace.trace.stage("sftp.open"). Preserve
the existing begin/end timing output in app builds while relying on the shim’s
no-op behavior for File Provider builds, and remove the surrounding `#if`
!REMUX_FILE_PROVIDER_EXTENSION guards.

In `@RemuxAppTests/FileProviderDomainReconcilerTests.swift`:
- Around line 230-237: Update the async mutate method to register a defer
immediately after incrementing activeMutationCount, ensuring the counter is
decremented whenever the method exits, including when Task.sleep throws due to
cancellation. Remove the direct decrement at the end while preserving the
existing mutation and maximum-concurrency updates.

In `@RemuxAppTests/FileProviderSharedStorageMigratorTests.swift`:
- Around line 212-221: Update XCTAssertThrowsErrorAsync to accept an
error-handling closure and invoke it with the caught error, while preserving the
existing XCTFail behavior when no error is thrown. Update
testMigrationFailureLeavesSourceIntactDoesNotMarkAndRetriesIdempotently to use
the handler and assert the expected migration failure rather than accepting
unrelated errors.

In `@RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift`:
- Around line 214-226: Register task cancellation with defer immediately after
the download task is created in the test, before the polling loop and readiness
assertion. Keep the existing cancellation call if needed only to preserve
behavior without duplication, and ensure task.value cannot block for the full
sleep when the assertion fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b60cffc9-855b-492e-a6c9-32ab5a0db69c

📥 Commits

Reviewing files that changed from the base of the PR and between 30b264f and de550b4.

📒 Files selected for processing (21)
  • Remux.xcodeproj/project.pbxproj
  • RemuxApp/Info.plist
  • RemuxApp/Remux.entitlements
  • RemuxApp/Sources/App/RemuxAppDependencies.swift
  • RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift
  • RemuxApp/Sources/Persistence/ApplicationStorage.swift
  • RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift
  • RemuxApp/Sources/Persistence/SSHCredentialStore.swift
  • RemuxApp/Sources/Persistence/TrustedHostStore.swift
  • RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift
  • RemuxApp/Sources/SSH/RemuxSFTPClient.swift
  • RemuxApp/Sources/SSH/RemuxSSHRootService.swift
  • RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift
  • RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift
  • RemuxAppTests/FileProviderDomainReconcilerTests.swift
  • RemuxAppTests/FileProviderSharedStorageMigratorTests.swift
  • RemuxAppTests/FileProviderSharedStorageTests.swift
  • RemuxAppTests/GhosttyTerminalDisconnectReasonClassifierTests.swift
  • RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift
  • RemuxAppTests/TerminalPreviewFileLoaderTests.swift
  • project.yml
💤 Files with no reviewable changes (1)
  • RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift

Comment on lines +9 to +13
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)dev.remux.app</string>
<string>$(AppIdentifierPrefix)dev.remux.shared</string>
</array>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The first keychain-access-groups entry becomes the default access group.

Adding this array changes the default keychain access group for every item saved without an explicit group. The default moves from the application-identifier group to $(AppIdentifierPrefix)dev.remux.app. Existing credentials were saved without a group, so they stay in the application-identifier group.

Confirm the bundle identifier. If it is not dev.remux.app, add $(AppIdentifierPrefix)$(CFBundleIdentifier) as the first entry so legacy items remain readable during migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Remux.entitlements` around lines 9 - 13, Update the
keychain-access-groups entries in the entitlements configuration to confirm the
bundle identifier; if it is not dev.remux.app, make
$(AppIdentifierPrefix)$(CFBundleIdentifier) the first entry so legacy
application-identifier keychain items remain readable during migration.

Comment on lines +156 to +169
(
application: KeychainSSHCredentialStore(
service: service,
accessGroup: try FileProviderSharedConfiguration.applicationKeychainAccessGroup(
infoDictionary: infoDictionary
)
),
shared: KeychainSSHCredentialStore(
service: service,
accessGroup: try FileProviderSharedConfiguration.keychainAccessGroup(
infoDictionary: infoDictionary
)
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The application store may not read legacy credentials.

This factory produces the migration source (application) and destination (shared). The application store queries with an explicit kSecAttrAccessGroup of RemuxApplicationKeychainAccessGroup. Legacy items were written with no access group, so they are in the application-identifier group. The two groups match only if the bundle identifier is dev.remux.app.

This is the same root cause raised on RemuxApp/Sources/Persistence/SSHCredentialStore.swift. Verify the group mapping before merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/App/RemuxAppDependencies.swift` around lines 156 - 169,
Correct the access-group mapping used to construct the migration source in the
dependency factory: ensure the application KeychainSSHCredentialStore uses the
application-identifier group that contains legacy credentials, while shared
continues using FileProviderKeychainAccessGroup. Verify the corresponding
FileProviderSharedConfiguration symbols and keep the migration destination
unchanged.

Comment on lines +42 to +60
func reconcile() async throws {
if let reconciliationTask {
try await reconciliationTask.value
return
}

let task = Task {
try await self.reconcileDomains()
}
reconciliationTask = task

do {
try await task.value
reconciliationTask = nil
} catch {
reconciliationTask = nil
throw error
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Coalescing can drop the state change that triggered the request.

If a reconciliation is already running, reconcile() awaits that task and returns. The in-flight task read its snapshot before the new caller arrived. Any change made after that read is not reconciled.

Concrete case: the migrator saves a credential, then calls reconcile(). A reconciliation started a moment earlier is still running. The caller joins it, sees success, and the new domain is never registered. The domain appears only after some later trigger.

Track a pending request and run one more pass after the current pass finishes.

♻️ Proposed change
     func reconcile() async throws {
         if let reconciliationTask {
-            try await reconciliationTask.value
-            return
+            isFollowUpRequested = true
+            _ = try? await reconciliationTask.value
+            if let followUpTask = reconciliationTask {
+                try await followUpTask.value
+                return
+            }
         }
 
         let task = Task {
             try await self.reconcileDomains()
         }
         reconciliationTask = task
 
         do {
             try await task.value
             reconciliationTask = nil
         } catch {
             reconciliationTask = nil
             throw error
         }
+
+        if isFollowUpRequested {
+            isFollowUpRequested = false
+            try await reconcile()
+        }
     }

Add the flag to the actor:

     private var reconciliationTask: Task<Void, Error>?
+    private var isFollowUpRequested = false

Adjust testReconcileAddsRenamesAndRemovesToMatchDesiredSet, which currently asserts recordsRequestCount == 1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift` around
lines 42 - 60, The reconcile() coalescing logic can lose changes arriving while
a pass is in flight. Add actor state to record a pending reconciliation request;
when reconcile() is called during an existing reconciliationTask, mark it
pending and await the current task, then ensure reconcileDomains() runs one
additional pass before clearing the task when pending is set. Update
testReconcileAddsRenamesAndRemovesToMatchDesiredSet to expect the additional
reconciliation request.

Comment on lines +70 to +75
try sharedTrust.replaceIdentities(trustedIdentities)

guard try await sharedProfiles.loadSnapshot() == snapshot,
try sharedTrust.loadIdentities() == trustedIdentities else {
throw FileProviderSharedStorageMigrationError.verificationFailed
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Strict snapshot equality can make the migration fail permanently.

replaceIdentities overwrites the shared trust file, and the guard requires the shared snapshot to equal the legacy snapshot exactly. Both assume the shared store is empty.

If the shared store already holds any server, workspace, or identity that is not in the legacy snapshot, the guard fails. migrateIfNeeded throws, the marker is never written, and every later launch repeats the same failure. There is no recovery path.

Verify that the shared snapshot contains the legacy records instead of requiring exact equality, and merge trust identities instead of replacing them.

♻️ Proposed change
-        try sharedTrust.replaceIdentities(trustedIdentities)
+        var mergedIdentities = try sharedTrust.loadIdentities()
+        for identity in trustedIdentities
+        where !mergedIdentities.contains(where: { $0.serverID == identity.serverID }) {
+            mergedIdentities.append(identity)
+        }
+        try sharedTrust.replaceIdentities(mergedIdentities)
 
-        guard try await sharedProfiles.loadSnapshot() == snapshot,
-              try sharedTrust.loadIdentities() == trustedIdentities else {
+        let migratedSnapshot = try await sharedProfiles.loadSnapshot()
+        let migratedTrust = try sharedTrust.loadIdentities()
+        guard snapshot.servers.allSatisfy(migratedSnapshot.servers.contains),
+              snapshot.workspaces.allSatisfy(migratedSnapshot.workspaces.contains),
+              snapshot.identities.allSatisfy(migratedSnapshot.identities.contains),
+              trustedIdentities.allSatisfy(migratedTrust.contains) else {
             throw FileProviderSharedStorageMigrationError.verificationFailed
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try sharedTrust.replaceIdentities(trustedIdentities)
guard try await sharedProfiles.loadSnapshot() == snapshot,
try sharedTrust.loadIdentities() == trustedIdentities else {
throw FileProviderSharedStorageMigrationError.verificationFailed
}
try sharedTrust.replaceIdentities(trustedIdentities)
var mergedIdentities = try sharedTrust.loadIdentities()
for identity in trustedIdentities
where !mergedIdentities.contains(where: { $0.serverID == identity.serverID }) {
mergedIdentities.append(identity)
}
try sharedTrust.replaceIdentities(mergedIdentities)
let migratedSnapshot = try await sharedProfiles.loadSnapshot()
let migratedTrust = try sharedTrust.loadIdentities()
guard snapshot.servers.allSatisfy(migratedSnapshot.servers.contains),
snapshot.workspaces.allSatisfy(migratedSnapshot.workspaces.contains),
snapshot.identities.allSatisfy(migratedSnapshot.identities.contains),
trustedIdentities.allSatisfy(migratedTrust.contains) else {
throw FileProviderSharedStorageMigrationError.verificationFailed
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/Persistence/FileProviderSharedStorageMigrator.swift` around
lines 70 - 75, Update the migration verification in migrateIfNeeded to validate
that the shared snapshot contains all legacy servers and workspaces rather than
requiring exact snapshot equality. Change the sharedTrust update from
replaceIdentities to merging the legacy trusted identities with existing shared
identities, while preserving existing entries and verifying the merged result.

Comment on lines 151 to +155
func realPath(atPath path: String) async throws -> String {
try await withOperationTimeout {
try await sftp.getRealPath(atPath: path)
try await connection.remuxRealPath(atPath: path)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize missing-path errors in realPath too.

listDirectory, metadata, and withFile convert a missing remote path into RemuxSFTPClientError.noSuchFile(path). realPath returns the raw Citadel status error. Callers that switch on RemuxSFTPClientError then miss the missing-path case for path resolution.

♻️ Proposed change
     func realPath(atPath path: String) async throws -> String {
-        try await withOperationTimeout {
-            try await connection.remuxRealPath(atPath: path)
-        }
+        do {
+            return try await withOperationTimeout {
+                try await connection.remuxRealPath(atPath: path)
+            }
+        } catch {
+            throw normalizedReadError(error, path: path)
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func realPath(atPath path: String) async throws -> String {
try await withOperationTimeout {
try await sftp.getRealPath(atPath: path)
try await connection.remuxRealPath(atPath: path)
}
}
func realPath(atPath path: String) async throws -> String {
do {
return try await withOperationTimeout {
try await connection.remuxRealPath(atPath: path)
}
} catch {
throw normalizedReadError(error, path: path)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift` around lines 151 - 155,
Update realPath(atPath:) to catch Citadel’s missing-path status and convert it
to RemuxSFTPClientError.noSuchFile(path), matching listDirectory, metadata, and
withFile; rethrow all other errors unchanged while preserving the existing
operation timeout behavior.

Comment on lines +130 to +149
try await withFile(atPath: remotePath) { remoteFile in
var offset: UInt64 = 0
while true {
try Task.checkCancellation()
let data = try await remoteFile.readChunk(
from: offset,
length: RemuxSFTPReadableFile.maximumChunkLength
)
try Task.checkCancellation()
guard !data.isEmpty else { break }

try Task.checkCancellation()
try localFile.write(contentsOf: data)
try Task.checkCancellation()

offset += UInt64(data.count)
await progress(Int64(min(offset, UInt64(Int64.max))))
try Task.checkCancellation()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the total downloaded bytes.

The loop stops only when the remote returns an empty chunk. A remote path that never reports EOF, for example a character device such as /dev/zero or a growing log, makes the loop write without limit. The File Provider container can then fill the disk. Add a byte budget, or read at most the size reported by metadata(atPath:).

🛡️ Example bound
-    func downloadFile(
-        atPath remotePath: String,
-        to localURL: URL,
-        progress: `@escaping` `@Sendable` (Int64) async -> Void
-    ) async throws {
+    func downloadFile(
+        atPath remotePath: String,
+        to localURL: URL,
+        maximumByteCount: UInt64 = 8 * 1024 * 1024 * 1024,
+        progress: `@escaping` `@Sendable` (Int64) async -> Void
+    ) async throws {
         do {
             try Data().write(to: localURL, options: .atomic)
             let localFile = try FileHandle(forWritingTo: localURL)
@@
                     offset += UInt64(data.count)
+                    guard offset <= maximumByteCount else {
+                        throw RemuxSFTPClientError.oversizedReadResult(
+                            requested: Int(clamping: maximumByteCount),
+                            actual: Int(clamping: offset)
+                        )
+                    }
                     await progress(Int64(min(offset, UInt64(Int64.max))))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxApp/Sources/SSH/RemuxSFTPClient.swift` around lines 130 - 149, Bound the
download loop in the SFTP transfer flow around remoteFile.readChunk by obtaining
the remote size from metadata(atPath:) or enforcing an explicit maximum byte
budget. Limit each read and stop once the budget or reported file size is
reached, while preserving cancellation checks, local writes, offset updates,
progress reporting, and EOF handling.

Comment on lines +119 to +138
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let profiles = FileBackedConnectionProfileRepository(rootURL: root)
let credentials = KeychainSSHCredentialStore(service: "dev.remux.tests.\(UUID().uuidString)")
let trust = TrustedHostStore(rootURL: root)

let servers = [
Self.server(id: "00000000-0000-0000-0000-000000000001", name: "Password"),
Self.server(id: "00000000-0000-0000-0000-000000000002", name: "Key"),
Self.server(id: "00000000-0000-0000-0000-000000000003", name: "Third"),
]
let eligibility = [passwordServer, keyServer, thirdServer]

for (server, eligibility) in zip(servers, eligibility) {
try await profiles.saveServer(server)

if eligibility != .missingCredential {
try await credentials.saveCredential(.password("secret"), identityID: server.identityID)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The fixture leaves credentials in the real keychain.

Line 122 creates a KeychainSSHCredentialStore, and line 136 saves up to three credentials per fixture. Nothing deletes them. The service name is random, so each test run adds items to the developer or CI keychain that no later run can find or clean.

Use an in-memory SSHCredentialStore for this fixture, or delete the saved credentials in a teardown. FileProviderSharedStorageMigratorTests.swift already defines an InMemorySSHCredentialStore; promote it to a shared test helper and reuse it here. The reconciler depends only on any SSHCredentialStore, so no production change is needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxAppTests/FileProviderDomainReconcilerTests.swift` around lines 119 -
138, Replace the KeychainSSHCredentialStore used by the fixture setup with the
shared in-memory SSHCredentialStore helper, promoting InMemorySSHCredentialStore
from FileProviderSharedStorageMigratorTests.swift if necessary. Keep the
existing credential-saving behavior and ensure the reconciler continues
receiving it through the any SSHCredentialStore interface, without changing
production code.

Comment on lines +37 to +53
func testLiveCredentialStoresKeepApplicationSourceSeparateFromSharedDestination() async throws {
let service = "dev.remux.tests.\(UUID().uuidString)"
let stores = try RemuxAppDependencies.fileProviderCredentialStores(service: service)
let identityID = UUID()

try await stores.application.saveCredential(.password("application"), identityID: identityID)
try await stores.shared.saveCredential(.password("shared"), identityID: identityID)

let applicationCredential = try await stores.application.loadCredential(identityID: identityID)
let sharedCredential = try await stores.shared.loadCredential(identityID: identityID)

XCTAssertEqual(applicationCredential, .password("application"))
XCTAssertEqual(sharedCredential, .password("shared"))

try await stores.application.deleteCredential(identityID: identityID)
try await stores.shared.deleteCredential(identityID: identityID)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check that the test target has a host application and entitlements.
fd -H 'project.yml' -x rg -n -C10 'RemuxAppTests'

Repository: h3nock/remux

Length of output: 912


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list matching test file =="
fd -a 'FileProviderSharedStorageTests.swift' .

echo "== project.yml around test targets =="
cat -n project.yml | sed -n '60,130p'

echo "== Xcode project files matching RemuxAppTests/test targets =="
git ls-files | rg '(^|/)project\.yml$|\.xcodeproj|\.xcworkspace|project\.' | sed -n '1,200p'

Repository: h3nock/remux

Length of output: 2840


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate remux app and app tests references =="
rg -n "Remux|RemuxAppTests|RemuxTests|hostApplication|APPLICATION_IDENTITY|keychain|entitlements|fileProviderCredentialStores|FileProviderCredentialStores" -S . --glob '!**/ghostty-remux-upstream-rebuild/**' || true

echo "== inspect candidate project files sizes =="
for f in $(fd -H 'project\.ya?ml$|project\.json$' .); do
  wc -l "$f"
done

Repository: h3nock/remux

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== test file around target storage and failing assertions =="
cat -n RemuxAppTests/FileProviderSharedStorageTests.swift | sed -n '1,140p'

echo "== FileProviderCredentialStores declarations =="
rg -n -C4 "FileProviderCredentialStores|fileProviderCredentialStores|deleteCredential|loadCredential|saveCredential" RemuxApp/Sources RemuxAppTests --glob '*.swift'

Repository: h3nock/remux

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== RemuxAppDependencies:"
rg -n -C8 "fileProviderCredentialStores|FileProviderCredentialStores|remux tests" RemuxApp RemuxAppTests --glob '*.swift'

echo "== project.yml config around Remux and RemuxTests build settings:"
cat -n project.yml | sed -n '20,100p'

Repository: h3nock/remux

Length of output: 6914


Clean the live keychain items from every teardown path.

This test writes to KeychainSSHCredentialStore on the test target path. If an assertion fails or the method exits early, the deletes on lines 51-52 never run and two random-service keychain items remain. Register the cleanup with addTeardownBlock immediately after creating the stores, then remove the trailing deletes from the happy-path block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxAppTests/FileProviderSharedStorageTests.swift` around lines 37 - 53, In
testLiveCredentialStoresKeepApplicationSourceSeparateFromSharedDestination,
register an addTeardownBlock immediately after creating stores to delete the
application and shared credentials for the generated identityID on every exit
path, then remove the trailing happy-path delete calls.

Comment on lines +286 to +289
func readData(from offset: UInt64, length: UInt32) -> Data {
recordedRequests.append(.init(offset: offset, length: Int(length)))
return chunks.removeFirst()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Avoid a fatal trap when the chunk list is exhausted.

chunks.removeFirst() traps on an empty array. If a regression makes downloadFile perform one more read than expected, the test process crashes and the remaining tests do not report. Return an empty Data instead and let the assertion on requests() detect the extra read.

💚 Proposed change
     func readData(from offset: UInt64, length: UInt32) -> Data {
         recordedRequests.append(.init(offset: offset, length: Int(length)))
-        return chunks.removeFirst()
+        guard !chunks.isEmpty else { return Data() }
+        return chunks.removeFirst()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func readData(from offset: UInt64, length: UInt32) -> Data {
recordedRequests.append(.init(offset: offset, length: Int(length)))
return chunks.removeFirst()
}
func readData(from offset: UInt64, length: UInt32) -> Data {
recordedRequests.append(.init(offset: offset, length: Int(length)))
guard !chunks.isEmpty else { return Data() }
return chunks.removeFirst()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift` around lines 286 - 289,
Update readData(from:length:) to return empty Data when chunks is exhausted
instead of calling removeFirst() on an empty array, while preserving request
recording and normal chunk consumption so requests() detects unexpected reads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant