Skip to content

File Provider: add strict writes and contained remote items - #33

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

File Provider: add strict writes and contained remote items#33
obra wants to merge 7 commits into
h3nock:mainfrom
obra:codex/file-provider-review/write-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/read-transport
  • Branch: codex/file-provider-review/write-transport
  • Production delta: 438 additions / 12 deletions (F6–F7)

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

1f9e708 fileprovider: model contained remote paths and items
b8669b9 sftp: add strict File Provider write operations

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 browsing and managing remote files.
    • Added shared storage and credential access across the app and File Provider extension.
    • Added migration of existing profiles, credentials, and trusted hosts.
    • Added secure path, symlink, and remote item validation.
    • Expanded SFTP support with directory browsing, uploads, downloads, links, renames, and removals.
    • Added transport startup performance tracing.
  • Bug Fixes

    • Hostname changes now correctly require renewed host-key trust.
    • Improved SFTP error handling, cancellation, progress reporting, and cleanup.

obra added 7 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.
Implement strict exclusive SFTP upload creation together with explicit directory creation, rename, file removal, and empty-directory removal operations.

Normalize Citadel write statuses into typed permission and unsupported-mutation errors. Close remote upload handles on cancellation without issuing later mutations, and keep terminal attachments on the shared SFTP upload behavior.

Evidence: the write tests produced a compile failure on the absent contracts before the source changes; all 38 focused tests passed afterward; the normal Remux iPhone 17 simulator build passed with isolated DerivedData and the required compiler probes; exact blob, scope, line-count, project-file, and diff checks passed.
Reconstruct the accepted remote-item model as one reviewable F7 commit directly on the strict SFTP write transport boundary. This moves only the immutable accepted bytes for canonical relative paths, File Provider identifier encoding, contained symlink projection, remote-item metadata/versioning, and their 12 focused tests.

Accepted provenance:
- FileProviderRemotePath.swift: 16813f6
- FileProviderRemoteItem.swift: 859c016
- FileProviderRemoteItemTests.swift: f8527a8
- source commits: df3cd2c, f2f82db, and 9fcd3fd

Verification:
- parent File Provider storage/domain suites: 10/10 passing
- tests-first RED: FileProviderRemoteItemTests failed only for the absent accepted model types
- GREEN: FileProviderRemoteItemTests 12/12 passing
- normal iPhone 17 simulator build passed
- XcodeGen 2.44.1 was deterministic across two generations
- project.yml remains byte-identical to the parent
- the 12 generated PBX membership lines are an accepted-oracle subset

This deliberately excludes FileProviderRemoteService changes, stable opaque identity work, writable projection contracts, File Provider extension activation, compatibility behavior, and any newly authored product or test logic.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The project adds FileProvider support with shared app-group storage, keychain access groups, migration, domain reconciliation, remote item models, and SFTP file operations. It also adds extension-specific tracing behavior and comprehensive async tests.

FileProvider integration

Layer / File(s) Summary
Shared storage and project wiring
Remux.xcodeproj/project.pbxproj, RemuxApp/Info.plist, RemuxApp/Remux.entitlements, RemuxApp/Sources/App/..., RemuxApp/Sources/Persistence/..., project.yml
The app configures FileProvider, shared storage, entitlements, keychain stores, trusted identities, and verified legacy-data migration.
Remote paths, items, and domain reconciliation
RemuxApp/Sources/FileProvider/*, RemuxAppTests/FileProviderDomainReconcilerTests.swift, RemuxAppTests/FileProviderRemoteItemTests.swift
The code validates remote paths and identifiers, models remote items and safe links, and reconciles eligible servers with registered FileProvider domains.
SFTP provider abstraction and file operations
RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift, RemuxApp/Sources/SSH/RemuxSFTPClient.swift, RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift, RemuxAppTests/TerminalPreviewFileLoaderTests.swift
SFTP access now supports abstract connections, metadata, directory operations, uploads, downloads, mutations, cancellation, progress, and normalized errors.
Transport tracing and SSH integration
RemuxApp/Sources/SSH/RemuxTransportStartupTrace.swift, RemuxApp/Sources/SSH/RemuxSSHRootService.swift, RemuxApp/Sources/Tmux/GhosttyRuntimeTrace.swift
Transport startup tracing moves to a dedicated implementation, while FileProvider extensions exclude runtime latency tracing and SSH root initialization accepts resolved authentication data.

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

Possibly related PRs

  • h3nock/remux#8: Both changes update application dependency wiring and persistence/SSH components.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.62% 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 PR's main changes: strict SFTP writes and contained remote-item models for File Provider.
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: 1f9e708885

ℹ️ 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".

case .write:
return try await connection.remuxOpenFileForWriting(
atPath: remotePath,
flags: [.write, .create, .forceCreate]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve retryable terminal attachment uploads

When a terminal attachment write times out after the server creates its remote file, GhosttyAttachmentTransfer.upload deliberately skips cleanup for .operationTimedOut, while the path builder reuses the deterministic .<filename>.part name on the next attempt. Opening every upload with .forceCreate makes that retry—and all later uploads of the same filename—fail on the stale partial file; keep exclusive creation specific to File Provider writes or otherwise retain retry/cleanup behavior for attachment staging.

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: 5

🧹 Nitpick comments (5)
RemuxApp/Sources/Persistence/ApplicationStorage.swift (1)

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

Remove or use the fileManager parameter.

sharedRemuxRoot accepts fileManager but never uses it. remuxRoot uses its fileManager to create the directory, so the two functions behave differently for the same parameter name. Either create the directory here, or drop the parameter.

♻️ Option: create the shared root directory
     ) throws -> URL {
         guard let containerURL = containerURL(appGroupIdentifier) else {
             throw FileProviderSharedConfigurationError.missingSharedContainer
         }
 
-        return containerURL.appendingPathComponent("Remux", isDirectory: true)
+        let root = containerURL.appendingPathComponent("Remux", isDirectory: true)
+        try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
+        return root
     }
🤖 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 fileManager parameter is either removed or used
consistently; prefer creating the “Remux” directory through fileManager before
returning the URL, matching remuxRoot’s behavior and preserving its existing
error handling.
RemuxApp/Sources/Persistence/SSHCredentialStore.swift (1)

166-188: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Set kSecAttrAccessible when adding shared credential keychain items.

saveCredential creates new Shared credential items with SecItemAdd, but the query used for both update and add carries no kSecAttrAccessible, so new shared items use the default unlocked-only accessibility. File Provider background sync can read Shared items while the device is locked, which fails with errSecInteractionNotAllowed. Add an explicit accessibility value, such as kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, on the add path only; do not set it in the match 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 and the add-item attributes around query to set
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly only when
constructing attributes for SecItemAdd. Keep query’s match fields unchanged so
the accessibility value is not used for lookup or update matching.
RemuxApp/Sources/Persistence/TrustedHostStore.swift (1)

88-92: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Document that replaceIdentities bypasses trust validation.

restoreIdentity carries an explicit warning at lines 59-62 that it bypasses host-key challenge and trust-transition validation. replaceIdentities is a wider bypass. It overwrites the whole trust store with no validation. Add an equivalent doc comment so future callers do not use it to establish new trust.

📝 Proposed doc comment
+    /// Replaces the entire trust store contents.
+    ///
+    /// This intentionally bypasses host-key challenge and trust-transition
+    /// validation. Callers must not use it to establish new trust. It exists
+    /// for migration and snapshot restore only.
     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 doc comment immediately above replaceIdentities(_:) stating that it
bypasses host-key challenge and trust-transition validation, overwrites the
entire trust store without validation, and must not be used to establish new
trust.
RemuxAppTests/FileProviderDomainReconcilerTests.swift (1)

113-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tests write real keychain items without guaranteed cleanup. Both test files save credentials through a live KeychainSSHCredentialStore under a randomized service name, and neither guarantees deletion. Temporary directories are reclaimed by the OS, but keychain items persist, so every run accumulates orphan entries on developer machines and on CI.

  • RemuxAppTests/FileProviderDomainReconcilerTests.swift#L113-L138: replace the live KeychainSSHCredentialStore at line 122 with an in-memory SSHCredentialStore. These tests verify domain reconciliation, not keychain behaviour, so nothing is lost.
  • RemuxAppTests/FileProviderSharedStorageTests.swift#L37-L53: keep the live store, because the test verifies real access-group separation. Move the deletes at lines 51-52 into an addTeardownBlock registered before the first save, so cleanup runs when an earlier call 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 `@RemuxAppTests/FileProviderDomainReconcilerTests.swift` around lines 113 -
138, The test setup in RemuxAppTests/FileProviderDomainReconcilerTests.swift,
within init, should use the in-memory SSHCredentialStore instead of
KeychainSSHCredentialStore. In
RemuxAppTests/FileProviderSharedStorageTests.swift, preserve the live store
required by the access-group test, but move both credential deletions into an
addTeardownBlock registered before the first save so cleanup runs even when
setup throws.
RemuxAppTests/FileProviderSharedStorageMigratorTests.swift (1)

49-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the concrete error in the migration failure test.

XCTAssertThrowsErrorAsync discards the thrown error at line 220. The test at line 49 therefore passes for any failure, including an unrelated file-system error raised by saveIdentity before FailingOnceCredentialStore is ever reached. That would silently hide a regression in the credential copy path.

Forward the error to a caller-supplied handler and assert the expected case.

♻️ Proposed change to surface the thrown error
 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)
+    }
 }

Then tighten the call site. Failure is currently private to FailingOnceCredentialStore; expose it or assert on the store's own marker:

         await XCTAssertThrowsErrorAsync {
             try await fixture.migrator.migrateIfNeeded()
+        } errorHandler: { error in
+            XCTAssertFalse(error is FileProviderSharedStorageMigrationError, "expected the store failure, not a verification failure")
         }

Also applies to: 212-221

🤖 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 49 -
51, Update XCTAssertThrowsErrorAsync to accept and invoke a caller-supplied
error handler with the thrown error, then tighten the migration failure test
around fixture.migrator.migrateIfNeeded() to assert the expected
FailingOnceCredentialStore failure rather than merely any thrown error; expose
its Failure type or use the store’s existing failure marker as appropriate.
🤖 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/Sources/FileProvider/FileProviderDomainReconciler.swift`:
- Around line 42-60: Update FileProviderDomainReconciler.reconcile() to track
requests received while reconciliationTask is active, then execute one
additional reconcileDomains() pass after the current task completes when such a
request is pending. Preserve serialized mutation execution and clear the pending
state appropriately; update testReconcileAddsRenamesAndRemovesToMatchDesiredSet
to expect two requests while retaining the maximumConcurrentMutationCount == 1
assertion.
- Around line 81-89: Update reconcileDomains() to distinguish removed or moved
domains from renamed-only records. Keep serverID, trusted-host, and credential
changes on the registry.remove/add path, but for records whose only change is
displayName, reuse the existing domain and update it through the add operation
without removing it first.

In `@RemuxApp/Sources/FileProvider/FileProviderRemoteItem.swift`:
- Around line 14-20: Guard the timestamp conversion in contentVersion by
replacing the trapping Int64($0.timeIntervalSince1970) conversion with
Int64(exactly:) and an appropriate fallback or clamping behavior. Verify
RemuxSFTPFileMetadata.modificationDate construction sites first, but ensure
malformed, non-finite, or out-of-range remote timestamps cannot crash the
process.

In `@RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift`:
- Around line 460-472: Update isNoSuchFile to unwrap SFTPError.errorStatus and
evaluate its contained status code, reusing the same status-code unwrapping
logic as normalizedWriteError. Ensure direct RemuxSFTPClientError.noSuchFile and
SFTPMessage.Status handling remain supported so normalizedReadError,
ensureDirectoryExists, and exists recognize missing paths consistently.

In `@RemuxAppTests/RemuxSFTPReadOnlyClientTests.swift`:
- Around line 410-413: Update the readData fixture method to return empty Data
when chunks is exhausted instead of calling removeFirst() on an empty array;
preserve recording each request and return the next chunk when available.

---

Nitpick comments:
In `@RemuxApp/Sources/Persistence/ApplicationStorage.swift`:
- Around line 57-71: Update sharedRemuxRoot so its fileManager parameter is
either removed or used consistently; prefer creating the “Remux” directory
through fileManager before returning the URL, matching remuxRoot’s behavior and
preserving its existing error handling.

In `@RemuxApp/Sources/Persistence/SSHCredentialStore.swift`:
- Around line 166-188: Update saveCredential and the add-item attributes around
query to set kSecAttrAccessible to
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly only when constructing
attributes for SecItemAdd. Keep query’s match fields unchanged so the
accessibility value is not used for lookup or update matching.

In `@RemuxApp/Sources/Persistence/TrustedHostStore.swift`:
- Around line 88-92: Add a doc comment immediately above replaceIdentities(_:)
stating that it bypasses host-key challenge and trust-transition validation,
overwrites the entire trust store without validation, and must not be used to
establish new trust.

In `@RemuxAppTests/FileProviderDomainReconcilerTests.swift`:
- Around line 113-138: The test setup in
RemuxAppTests/FileProviderDomainReconcilerTests.swift, within init, should use
the in-memory SSHCredentialStore instead of KeychainSSHCredentialStore. In
RemuxAppTests/FileProviderSharedStorageTests.swift, preserve the live store
required by the access-group test, but move both credential deletions into an
addTeardownBlock registered before the first save so cleanup runs even when
setup throws.

In `@RemuxAppTests/FileProviderSharedStorageMigratorTests.swift`:
- Around line 49-51: Update XCTAssertThrowsErrorAsync to accept and invoke a
caller-supplied error handler with the thrown error, then tighten the migration
failure test around fixture.migrator.migrateIfNeeded() to assert the expected
FailingOnceCredentialStore failure rather than merely any thrown error; expose
its Failure type or use the store’s existing failure marker as appropriate.
🪄 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: 14fdfdd7-41e8-4ae1-8d2e-2df75fbdef44

📥 Commits

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

📒 Files selected for processing (24)
  • Remux.xcodeproj/project.pbxproj
  • RemuxApp/Info.plist
  • RemuxApp/Remux.entitlements
  • RemuxApp/Sources/App/RemuxAppDependencies.swift
  • RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift
  • RemuxApp/Sources/FileProvider/FileProviderRemoteItem.swift
  • RemuxApp/Sources/FileProvider/FileProviderRemotePath.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/FileProviderRemoteItemTests.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 +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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Coalescing can drop a reconciliation that was requested mid-pass.

reconcileDomains reads the snapshot at line 63. A caller that invokes reconcile() after that read joins the in-flight task at line 44 and returns when it finishes. That caller's changes are never read.

Concretely: the app saves an edited server, then calls reconcile(). If a pass is already running and has passed line 63, the new host or display name is not applied. The domain stays stale until an unrelated later call.

Track a pending-request flag and run one more pass when a request arrives during an active pass.

🔧 Proposed coalescing that honours late requests
     private var reconciliationTask: Task<Void, Error>?
+    private var reconciliationRequested = false
 
     func reconcile() async throws {
         if let reconciliationTask {
+            reconciliationRequested = true
             try await reconciliationTask.value
+            if reconciliationRequested {
+                try await reconcile()
+            }
             return
         }
 
+        reconciliationRequested = false
         let task = Task {
             try await self.reconcileDomains()
         }
         reconciliationTask = task
 
         do {
             try await task.value
             reconciliationTask = nil
+            reconciliationRequested = false
         } catch {
             reconciliationTask = nil
+            reconciliationRequested = false
             throw error
         }
     }

Note that this changes the observable counts in testReconcileAddsRenamesAndRemovesToMatchDesiredSet. The second concurrent caller would drive a second pass, so recordsRequestCount becomes 2. Update that assertion, and keep the maximumConcurrentMutationCount == 1 assertion, which still holds.

🤖 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, Update FileProviderDomainReconciler.reconcile() to track requests
received while reconciliationTask is active, then execute one additional
reconcileDomains() pass after the current task completes when such a request is
pending. Preserve serialized mutation execution and clear the pending state
appropriately; update testReconcileAddsRenamesAndRemovesToMatchDesiredSet to
expect two requests while retaining the maximumConcurrentMutationCount == 1
assertion.

Comment on lines +81 to +89
for record in existingRecords
where desiredRecords.first(where: { $0.serverID == record.serverID }) != record {
try await registry.remove(serverID: record.serverID)
}

for record in desiredRecords
where existingRecords.first(where: { $0.serverID == record.serverID }) != record {
try await registry.add(record)
}

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

🧩 Analysis chain

🌐 Web query:

NSFileProviderManager change existing domain displayName without removing domain

💡 Result:

Yes, you can update the display name of an existing NSFileProviderDomain without removing the domain by calling the add(:completionHandler:) method again with the same identifier [1]. According to technical documentation and implementation patterns, if a domain with a specific identifier already exists, calling the add(:completionHandler:) method on NSFileProviderManager will update the domain's properties—including its displayName and hidden state—rather than creating a new one or failing [1]. This allows you to programmatically refresh or change the display name as seen in the user interface without triggering the overhead or potential data implications of a full domain removal and re-registration [1]. Note that while this is the intended mechanism for updating domain metadata, some developers have reported limitations regarding how consistently this name is reflected in specific parts of the Finder sidebar, sometimes noting that the application name or other factors may influence the final appearance in the user interface [2]. If you encounter issues where the name does not immediately update, ensure that the identifier remains exactly the same so the system recognizes the request as an update to an existing domain [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the reconciler and nearby tests without executing repository code.
fd -a 'FileProviderDomainReconciler.swift|FileProvider.*Reconcile|.*Reconcile.*' . | sed 's#^\./##' | while read -r f; do
  echo "===== $f ====="
  wc -l "$f"
done

echo "===== Relevant source ====="
sed -n '1,140p' RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift 2>/dev/null || true

echo "===== Test mentions ====="
rg -n "testReconcileAddsRenamesAndRemovesToMatchDesiredSet|displayName|reconcile|removes?|adds?|renames?" Tests remux app Sources -S || true

echo "===== Manifest/deployment target evidence ====="
fd 'Package.swift|Project.swift|Info.plist|deployment_target|iOSDeploymentTarget|macOSDeploymentTarget' . | sed 's#^\./##' | while read -r f; do
  echo "--- $f ---"
  sed -n '1,220p' "$f" | rg -n "deployment_target|iOS|macOS|XCFramework|NSFileProvider|FileProvider|swift-tools|targets|ios|macos" -C 3 || true
done

Repository: h3nock/remux

Length of output: 5702


🌐 Web query:

Apple NSFileProviderManager add domain exists display name update same identifier domain manager add existing domain display name

💡 Result:

When using the NSFileProviderManager class to register domains, if you attempt to add a domain using an identifier that is already registered, the add(_:completionHandler:) method (or addDomain:completionHandler: in Objective-C) will update the display name and hidden state of the existing domain [1]. This operation will succeed provided that the domain is not backed by an NSFileProviderReplicatedExtension at a disk location that already exists, which would result in an NSFileWriteFileExistsError [1]. In summary, calling the method with the same identifier is the intended way to update the display name of an existing file provider domain [1]. Developers have reported, however, that the actual display name visible in the Finder sidebar may not always reflect the value provided in the NSFileProviderDomain object as expected, leading to requests for more granular control over sidebar naming [2].

Citations:


🌐 Web query:

"NSFileProviderManager add" existing domain display name update existing domain

💡 Result:

Yes, you can update the display name of an existing NSFileProviderDomain by calling the NSFileProviderManager.add(_:completionHandler:) method again with the same domain identifier [1][2]. When you call add(_:completionHandler:) with a NSFileProviderDomain object that shares an identifier with a domain already registered in the NSFileProviderManager, the system will update the display name (and hidden state) of that existing domain rather than failing [1][2]. It is important to note that while updating the display name via this method is supported, attempting to "add" a domain that already exists (with the same identifier) and expects to create new underlying disk locations or specific configurations may fail with an NSFileWriteFileExistsError if the system cannot perform the requested operation (such as re-initializing replicated storage) [1][2]. Always ensure that the NSFileProviderDomain object passed to the method carries the correct, matching identifier you wish to update [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== Directory tree around test/source ====="
find . -maxdepth 4 \( -path './.git' -o -path './Build' -o -path './.build' \) -prune -o \( -type d -o -type f \) -print | sort | rg 'FileProvide|RemuxApp' || true

echo "===== Test outline/source relevant lines ====="
wc -l RemuxAppTests/FileProviderDomainReconcilerTests.swift
sed -n '1,270p' RemuxAppTests/FileProviderDomainReconcilerTests.swift

echo "===== Complete source for registry/test classes ====="
sed -n '120,230p' RemuxApp/Sources/FileProvider/FileProviderDomainReconciler.swift

echo "===== Project manifest files ====="
git ls-files | rg '(^|/)(Project\.swift|Package\.swift|.*\.xcodeproj/.*|Info\.plist|Info\.Plist)$' | while read -r f; do
  echo "--- $f ---"
  sed -n '1,220p' "$f" | rg -n "deployment_target|iOS|macOS|Swift|FileProvider|InfoPlist|MinimumOSVersion|Info.plist" -C 4 || true
done

Repository: h3nock/remux

Length of output: 36704


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== Build settings from project file ====="
python3 - <<'PY'
from pathlib import Path
p = Path('Remux.xcodeproj/project.pbxproj')
for i, line in enumerate(p.read_text().splitlines(), 1):
    if any(key in line for key in ['IPHONEOS_DEPLOYMENT_TARGET', 'MACOSX_DEPLOYMENT_TARGET', 'PLATFORM_PREFERRED_VERSION', 'COMBINE_HIDPI_IMAGES', 'BASE_SDK']):
        print(f"{i:5}: {line}")
PY

echo "===== Read-only state model for current remove/add condition ====="
python3 - <<'PY'
existing = [
    ("s1", "Old Password"),
    ("s2", "Key"),
]
desired = [
    ("s1", "Password"),
    ("s2", "Key"),
]
def first_where(records, field, value):
    return next((r for r in records if r[0] == value), None)
remove = [r for r in existing if first_where(desired, "serverID", r[0]) != r]
add = [r for r in desired if first_where(existing, "serverID", r[0]) != r]
print("remove_count", len(remove), "add_count", len(add))
print("server1_in_remove", any(r[0] == "s1" for r in remove))
print("server1_in_add", any(r[0] == "s1" and r[1] == "Password" for r in add))
PY

Repository: h3nock/remux

Length of output: 700


🌐 Web query:

Apple NSFileProviderDomain update domain display name addDomain already exists NSFileProviderManager addDomain

💡 Result:

In the File Provider framework, you can update the display name of an existing NSFileProviderDomain by calling the NSFileProviderManager.add(_:completionHandler:) method again using the same domain identifier [1]. When you call addDomain for a domain identifier that is already registered, the system does not treat it as an error [1]. Instead, it updates the properties of that domain, such as the display name and hidden state, to match the new NSFileProviderDomain object provided in the call [1]. Key details regarding this process include: - Consistency: You must use the same unique identifier (NSFileProviderDomainIdentifier) for the domain you wish to update [1][2]. - Behavior: If the system recognizes the identifier as already existing, the addDomain call will succeed and apply the new configuration (e.g., updated displayName) [1]. - Limitations: While this method updates the display name, developers have noted that actual control over the Finder sidebar display can be complex and may not always behave as expected depending on how the system manages the domain alias [3]. - Errors: You should be aware that if the domain is backed by a specific disk location (e.g., using a replicated extension) and there are conflicts with that underlying disk location, the call might fail with errors such as NSFileWriteFileExistsError [1]. Always ensure your implementation handles the completion handler error to debug any issues during the update process [4][5].

Citations:


Use the existing domain for screen-name updates.

reconcileDomains() removes the server domain whenever server.displayName changes, then adds the same identifier again. NSFileProviderManager.add(domain) can update an existing domain by ID, so split the comparison into removed/moved identifiers and renamed-only records. Keep only identifier, trusted-host, and credential changes on the remove/add 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/FileProvider/FileProviderDomainReconciler.swift` around
lines 81 - 89, Update reconcileDomains() to distinguish removed or moved domains
from renamed-only records. Keep serverID, trusted-host, and credential changes
on the registry.remove/add path, but for records whose only change is
displayName, reuse the existing domain and update it through the add operation
without removing it first.

Comment on lines +14 to +20
var contentVersion: Data {
var data = Data()
data.appendString(type.rawValue)
data.appendOptional(size)
data.appendOptional(modificationDate.map { Int64($0.timeIntervalSince1970) })
return data
}

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

Guard the Int64 conversion of timeIntervalSince1970.

Int64($0.timeIntervalSince1970) traps if the Double is NaN, infinite, or outside the Int64 range. modificationDate originates from remote SFTP attributes, so a malicious or broken server can supply an out-of-range timestamp and crash the process. Use Int64(exactly:) with a fallback, or clamp the value.

🛡️ Proposed fix
-        data.appendOptional(modificationDate.map { Int64($0.timeIntervalSince1970) })
+        data.appendOptional(modificationDate.map { date -> Int64 in
+            let seconds = date.timeIntervalSince1970.rounded(.down)
+            guard seconds.isFinite else { return 0 }
+            return Int64(seconds.clamped(to: Double(Int64.min)...Double(Int64.max)))
+        })

Verify how RemuxSFTPFileMetadata.modificationDate is produced. If it is always derived from a bounded 32-bit SFTP timestamp, the trap is unreachable and this can be closed.

#!/bin/bash
# Locate the metadata model and every construction site of modificationDate.
fd -t f 'RemuxSFTPClient.swift|RemuxCitadelSFTPClient.swift' --exec cat -n {}
rg -n -C4 'modificationDate' --glob '*.swift'
🤖 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/FileProviderRemoteItem.swift` around lines 14 -
20, Guard the timestamp conversion in contentVersion by replacing the trapping
Int64($0.timeIntervalSince1970) conversion with Int64(exactly:) and an
appropriate fallback or clamping behavior. Verify
RemuxSFTPFileMetadata.modificationDate construction sites first, but ensure
malformed, non-finite, or out-of-range remote timestamps cannot crash the
process.

Comment on lines 460 to +472
private func isNoSuchFile(_ error: Error) -> Bool {
if case .noSuchFile = error as? RemuxSFTPClientError {
return true
}
guard let status = error as? SFTPMessage.Status else {
return false
}
return status.errorCode == .noSuchFile
}

private func normalizedReadError(_ error: Error, path: String) -> Error {
isNoSuchFile(error) ? RemuxSFTPClientError.noSuchFile(path) : 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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect Citadel SFTP error throwing sites to confirm the wrapper types.
set -euo pipefail

fd -t d -i 'citadel' . | head
rg -n -C4 'case errorStatus|throw SFTPError|throw response|SFTPMessage.Status' --iglob '*.swift' | head -80

Repository: h3nock/remux

Length of output: 150


🏁 Script executed:

#!/bin/bash
set -ux

printf 'Tracked files matching RemuxCitadelSFTPClient.swift:\n'
git ls-files | rg 'RemuxCitadelSFTPClient.swift|Citadel|citadel' || true

printf '\nCandidate Swift files in SSH/SFTP related directories:\n'
fd -e swift . . 2>/dev/null | rg -i 'sftp|citadel|ssh|remux' | head -200 || true

printf '\nSearch status-code related terms in Swift files:\n'
rg -n -C3 'case \.errorStatus|errorStatus|SFTPStatusCode|SFTPMessage\.Status|normalizedWriteError|isNoSuchFile|normalizedReadError|removeFileIfExists|ensureDirectoryExists|metadata\(|listDirectory\(|withFile\(' -g '*.swift' . | head -200 || true

Repository: h3nock/remux

Length of output: 29488


🏁 Script executed:

#!/bin/bash
set -ux

printf 'RemuxCitadelSFTPClient.swift relevant sections:\n'
sed -n '1,340p' RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift | cat -n

printf '\nRemuxSFTPClient.swift relevant sections:\n'
sed -n '1,260p' RemuxApp/Sources/SSH/RemuxSFTPClient.swift | cat -n

Repository: h3nock/remux

Length of output: 24315


🌐 Web query:

Citadel SFTP Swift errorStatus SFTPError errorStatus SFTPMessage Status

💡 Result:

In the Citadel Swift library, SFTPError.errorStatus is an enum case used to represent a failure returned by the SFTP server [1][2]. This error case carries an associated value of type SFTPMessage.Status, which provides the specific status information returned by the server [1][3]. When an SFTP request (such as opening a file or setting attributes) is sent, the client checks the response received from the server [4]. If the server responds with a status message indicating an error (i.e., the error code is not.ok), the library throws this SFTPError.errorStatus case [4]. You can handle this error by catching the SFTPError and using a switch statement to access the underlying status, as demonstrated in the following example: do { try await sftp.openFile(filePath: "/path/to/file", flags:.read) } catch let error as SFTPError { switch error { case.errorStatus(let status): // status is an SFTPMessage.Status object print("SFTP error code: (status.errorCode)") print("SFTP error message: (status.message)") case.fileHandleInvalid: print("Invalid file handle") default: print("Other error: (error)") } } catch { print("General error: (error)") } The SFTPMessage.Status object typically contains an errorCode (indicating the nature of the error, such as permission denied or file not found) and a message (a descriptive string provided by the server) [4][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -ux

printf 'Remaining RemuxCitadelSFTPClient.swift section with error handling only:\n'
sed -n '340,490p' RemuxApp/Sources/SSH/RemuxCitadelSFTPClient.swift | cat -n

printf '\nProject files with Citade/Citadel references:\n'
rg -n 'Citadel|package\.dependencies|orlandos-nl|Citadel\.xcframework' -g '*' . | head -200 || true

printf '\nAny local docs/swiftpm lock/package references:\n'
git ls-files | rg -i 'Package.swift|Package.resolved|Package.swift.lock|README|CHANGELOG|Docs|docs|README.md|LICENSE' || true

Repository: h3nock/remux

Length of output: 23183


🌐 Web query:

h3nock Citadel SFTPError errorStatus enum SFTPMessage.Status

💡 Result:

In the Citadel SSH/SFTP library, SFTPError.errorStatus is an enum case that encapsulates an SFTPMessage.Status object [1][2]. This error is thrown when an SFTP operation returns a status message from the server that indicates a failure, meaning the returned error code is not.ok [3]. The SFTPMessage.Status struct contains an errorCode property, which provides specific information about the failure (such as.noSuchFile) [3][4]. Regarding the h3nock reference, repositories such as h3nock/remux utilize the Citadel library for SSH/SFTP functionality [5][6]. References to h3nock/Citadel often point to forks or pins of the official orlandos-nl/Citadel repository, which provides the underlying SFTP error handling mechanism [7][5][8]. Example usage for handling this error: do { try await sftp.openFile(filePath: "/path/to/file", flags:.read) } catch let error as SFTPError { switch error { case.errorStatus(let status): print("SFTP error code: (status.errorCode)") default: print("Other error: (error)") } }

Citations:


Handle SFTPError.errorStatus in isNoSuchFile.

Citadel wraps server SFTP failures in SFTPError.errorStatus; isNoSuchFile only checks SFTPMessage.Status. A missing-path read error in this shape leaves read/withFile errors unnormalized, and ensureDirectoryExists/exists miss the create branch because both rely on isNoSuchFile. Share the status-code unwrapping between isNoSuchFile and normalizedWriteError.

🤖 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 460 - 472,
Update isNoSuchFile to unwrap SFTPError.errorStatus and evaluate its contained
status code, reusing the same status-code unwrapping logic as
normalizedWriteError. Ensure direct RemuxSFTPClientError.noSuchFile and
SFTPMessage.Status handling remain supported so normalizedReadError,
ensureDirectoryExists, and exists recognize missing paths consistently.

Comment on lines +410 to +413
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.

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

Avoid a fatal trap when the fixture runs out of chunks.

chunks.removeFirst() traps on an empty array. If a regression makes downloadFile issue one more read than expected, the test process crashes and the rest of the test bundle does not run. Return an empty Data instead, so the assertions report the failure.

💚 Proposed fix
     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 410 - 413,
Update the readData fixture method to return empty Data when chunks is exhausted
instead of calling removeFirst() on an empty array; preserve recording each
request and return the next chunk when available.

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