Skip to content

Generate SSH keys in-app: no more PuTTYgen for SFTP connections#5

Merged
jhd3197 merged 81 commits into
mainfrom
dev
Jul 18, 2026
Merged

Generate SSH keys in-app: no more PuTTYgen for SFTP connections#5
jhd3197 merged 81 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Setting up key auth used to mean leaving Faro, wrestling PuTTYgen or ssh-keygen into producing the right format, remembering where you saved the thing, and then pasting a public key you hoped was correct. This PR folds that whole detour into the New Connection editor: pick Ed25519 (or RSA for older servers), optionally set a passphrase, and Faro writes the keypair, points the connection at it, and hands back the public-key line already copied to your clipboard. The tricky part was format — russh can only write PKCS#8 PEM, which OpenSSH itself refuses to load for Ed25519 ("error in libcrypto"), so a key generated the obvious way would work inside Faro and nowhere else. We generate through the ssh-key crate instead, which writes real -----BEGIN OPENSSH PRIVATE KEY----- files (bcrypt-pbkdf + aes256-ctr when encrypted, exactly like ssh-keygen) that both OpenSSH and russh's own loader read back. A stored ~/.ssh/… path now also resolves on connect, which it quietly didn't before.

Highlights

  • Generate an SSH keypair right in the New Connection editor — choose Ed25519 or RSA, add an optional passphrase, and the new connection is pointed at the key automatically.
  • The public-key line is copied to your clipboard the moment it's generated, ready to drop into the server's authorized_keys — with its SHA256:… fingerprint shown so you can confirm it.
  • Already have a key? Paste its path and copy the public half without regenerating (enter the passphrase first if it's encrypted).
  • Keys are written in the standard OpenSSH format, so they work with plain ssh and ssh-keygen too — not just inside Faro.
  • Existing keys are never silently clobbered: naming a path that's taken stops and offers an explicit "overwrite" toggle instead of a dead end.
  • Connections that store a ~/.ssh/id_... path now connect correctly — the leading ~ is expanded on load.
Technical changes
  • New src-tauri/src/keys.rs module (registered in lib.rs): generate, public_key_for, defaults, and expand_tilde. Private keys are written in native OpenSSH format via the ssh-key crate (encrypted with bcrypt-pbkdf + aes256-ctr when a passphrase is given); public keys are emitted as standard one-line OpenSSH (ssh-ed25519 AAAA… comment).
  • Added the ssh-key = "0.6" dependency (ed25519, rsa, encryption, getrandom) — already present as a russh transitive, now pulled directly for the generator. See the Cargo.toml comment for why russh's own PKCS#8 PEM output can't be used.
  • Three Tauri commands in commands.rs: ssh_key_defaults (suggests ~/.ssh + a non-colliding filename), generate_ssh_key, and ssh_public_key_for. The two key-deriving commands run on spawn_blocking because generation is CPU-bound (RSA-4096 in particular) and would otherwise stall the async runtime.
  • generate refuses to overwrite an existing key unless overwrite: true is set, so an existing key is never clobbered by accident.
  • session/mod.rs: the AuthMethod::Key connect path now runs the stored path through keys::expand_tilde before russh_keys::load_secret_key, which does no tilde expansion of its own — fixing silent failures for ~/.ssh/... paths and letting generator-written keys load.
  • Frontend types in types.ts (SshKeyType, GenerateKeyRequest, GeneratedKey, SshKeyDefaults) mirror the Rust structs; typed IPC wrappers sshKeyDefaults, generateSshKey, sshPublicKeyFor in ipc.ts (no raw invoke in the component).
  • ProfileEditor.tsx: key-auth section gains the generator UI — algorithm toggle, passphrase field, save-path input (prefilled from ssh_key_defaults), an overwrite escape hatch surfaced only after an "already exists" error, and a copyable public-key box with fingerprint. Clipboard copy on both generate and derive, with toasts for success and for the encrypted-key / passphrase-needed failure cases.
  • .gitignore: ignore the local .marketing/ scratch directory.

jhd3197 and others added 30 commits July 16, 2026 00:09
- Default file pane view is now Grid (was Details), with a one-time migration
  that flips existing installs still on the old default while respecting any
  explicit choice made afterward.
- fileIcons: add distinct glyphs/colours for design files (Photoshop, Illustrator,
  Figma, XD, Affinity, InDesign, CorelDRAW), 3D/CAD (blend/obj/fbx/gltf/stl/…),
  and more languages (Haskell/Clojure/Julia/Terraform/…) — so a PSD no longer
  looks like a JPG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Known file types now show their actual Material Icon Theme icon — the Photoshop
mark on a .psd, the Python logo on a .py, react_ts on .tsx, docker on a
Dockerfile — the same set VS Code file managers use. The prebuilt manifest
drives filename/extension → icon resolution (longest-extension match, e.g.
app.d.ts), and the SVGs bundle as hashed assets via import.meta.glob (offline,
no network). Folders, symlinks, and unmatched files keep the lucide glyphs.

MIT-licensed; notice added in THIRD_PARTY_LICENSES.md. Verified: tsc clean,
vite build emits the SVG assets, and the extension→icon mapping resolves to
real files (psd/ai/py/fig/docker/blend/…).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implementation-ready plan for an additive Iconify layer (offline-bundled,
permissive-licence sets) giving recognizable brand/protocol logos on
connections. Explicitly scoped NOT to touch file-type icons (Material Icon
Theme is more complete + carries the extension mapping Iconify lacks) or the
lucide UI icons. Added to ROADMAP as Track E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross-backend disk-usage treemap + size-ranked tree, opening as a workspace tab
like the SSH terminal. Reuses the RemoteFs walk so it works on SFTP/S3/FTP/Agent/
local; the differentiator is remote servers/buckets, with a shell du/find fast
path (SSH + exec-gated Agent) and object-store flat listing for scale. Canvas
treemap. Added to ROADMAP as Track F.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Plan 7 Directory Diff: any-backend (incl. remote↔remote) diff surfaced in GUI,
  faro-cli, and as an MCP faro_diff tool; reuses sync.rs's diff.
- Plan 8 Fleet Search: name+content search, exec rg/grep fast path + walk
  fallback, GUI/CLI/MCP.
- Plan 9 Fleet Skills: reshape of multi-server runner into AI-authorable,
  MCP-native reusable automations, built on the bridge's saved-commands +
  faro_exec + approvals; safety-gated.
- Plan 10 Scoped connection sharing: time-boxed, path-jailed, read-only sharing
  via the agent's pairing/policy/revocation (no login system); browser view
  deferred as 10b.
- ROADMAP: Tracks G-J + two near-term quick wins (editable chmod dialog, CLI
  conflict-policy flags).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The real sharing use case (remote employee, link-based, over the internet) needs
auth regardless of delivery, so the whole track is parked rather than shipping
the LAN-only half. Scoped-grant model stays the backbone for when login lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Safety hardening for the continuous folder-sync engine (Plan 2, Track A).

Exclude patterns: each pair gains a gitignore-style `exclude` list. Matching
files are filtered out of BOTH copies and deletes in the reconciler, so an
excluded path is never uploaded and — critically — never mirror-deleted on the
far side. Basename patterns (`node_modules`, `*.log`) match at any depth;
path patterns (`/dist`, `build/**`) anchor at the root and cover descendants.

Mirror-delete guard: gates the destructive half of a Mirror sync while letting
uploads through.
 - Empty/unreadable source root (unmounted drive, deleted folder, typo) no
   longer wipes the destination — the diff's "everything is gone" is refused.
 - A configurable per-reconcile delete cap (`mirrorDeleteCap`, default 100)
   bounds the blast radius; over-cap deletes are withheld with a warning
   surfaced on the pair.

The exclude + guard decision is factored into `apply_safety`, exercised by an
on-disk test that runs the real `sync::plan` over temp dirs and asserts the
filtering, the cap, and the empty-source refusal against actual files.

Frontend: New-sync-pair form gains an exclude textarea and (under Mirror) a
max-deletes-per-sync input; the pair row shows an exclude count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Plan 3

Cut the persistent state index out of Plan 2 (folder sync) into a new
Plan 3 — a shared scan engine + faro.db foundation that disk-usage, diff,
and search all build on, rather than sync-private plumbing.

Renumber every plan file to execution/build order and fix all
cross-references (titles, inline Plan-N mentions, .md links):
  3 = scan+index foundation   7 = fleet search
  4 = disk usage              8 = fleet skills
  5 = additional backends     9 = on-demand virtual folders
  6 = directory diff         10 = icons, 11 = sharing (deferred)

Add a "Plan build order" index to the ROADMAP and reorder its recommended
sequence to match. Plan 2 is now marked code-complete (Phases 1-2 + safety);
its remaining work is runtime verification, not code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduces `CLAUDE.md` as a repo working agreement covering Faro architecture, branch/release workflow, autonomy expectations, build/verification commands, and codebase layout. Updates `.gitignore` to exclude `.pr/` so local PR draft files from the `create-pr` skill are kept out of commits.
Add rusqlite (bundled) and a db.rs that opens one faro.db under the app
data dir with forward-only, user_version-versioned migrations. First table
is sync_state — folder-sync's per-file memory, keyed by (pair_id, rel_path)
with the source-side size/mtime plus an opaque change token. No behavior
change yet; later plans add tables as additive migrations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New scan.rs owns the recursive RemoteFs walk that sync.rs::plan used to do
inline. It lists directories with bounded concurrency (latency, not CPU,
dominates on SFTP/FTP) and exposes a progress callback + cooperative cancel
token for the visible consumers to come (disk usage, diff, search). sync::plan
now calls scan::walk_tree — behavior-preserving; the on-disk planner test
still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ChangeSignal (MtimeSize/Etag/Hash) to Capabilities and an optional etag
to DirEntry, so callers stop inferring "did this change" from mtime alone.
Every backend declares honestly: object stores report Etag and populate the
token from object_store's e_tag; SFTP/FTP/local/agent report MtimeSize. The
scan engine now carries the etag through to ScanEntry. Mirrored in the
file-ui DirEntry/Capabilities types as optional fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reconciler now loads the pair's sync_state, plans via sync::plan_indexed
(which consults the index for same-size edits the live size/mtime diff misses,
flagging them SyncReason::Edited), and snapshots the source tree back into the
index after each successful pass — seeding it on first run and pruning removed
files. This closes the same-size-edit gap and makes relaunch a no-op instead of
a full re-upload. Removing a pair clears its index rows.

Backends declare their change signal (ETag for object stores, mtime+size
otherwise); the index compares the opaque token when present. Added the "edited"
reason to the frontend SyncReason union + badge.

Verified: a real-Db + real-files test shows the stateless diff missing a
same-size edit under a newer dest mtime, the index catching it, and a seeded
index declining to re-upload; a file-based Db::open test covers the startup path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A ScanManager (shaped like TransferManager: Arc in AppState, scans in a
Mutex<HashMap>, progress via diskscan:// events, cancel via a shared
CancelToken) recursively sizes a directory over any RemoteFs by reusing the
shared scan engine (scan::walk) and folds the flat file list into an
aggregated size tree (DuNode: name/path/kind/size/children, sorted
largest-first). Phase 1 is the generic walk that works on every backend; the
ScanStrategy enum is wired for the exec/object fast paths in Phase 3.

Commands: diskscan_start/status/tree/cancel/forget, registered in lib.rs.
scan::ScanProgress gains bytes_found (additive; the sync poller ignores it) so
the explorer can show a live byte total. Unit-tested tree aggregation +
largest-first sort + root-path handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full-screen Disk Usage overlay (DiskUsageHost, driven by a dedicated
diskScanStore that owns the scan lifecycle: progress events, cancel, drill-down,
colour mode). The centrepiece is a Canvas squarified treemap (DiskTreemap) —
recursive binary-split layout, theme-aware fills read from the live CSS tokens,
hover readout + click-to-drill — beside a size-ranked "biggest items" list with
percentage-of-parent bars. Breadcrumb navigates the drilled path; Rescan/Cancel
and a by-type/by-depth colour toggle live in the header.

Entry point follows the openTerminal pattern: an optional analyzeDiskUsage hook
on the file-ui FileSystemAdapter, implemented in the Tauri adapter to open the
explorer, surfaced as a FilePane toolbar button (current dir) + a directory
context-menu item. materialIconUrl is now exported from @faro/file-ui so the
list matches the browser's file icons. Typed ipc wrappers + onDiskScanEvent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ting

The scan now picks the fastest strategy from the live Session, falling back to
the generic walk on any failure so a scan never hard-fails:

- Object stores (S3/Azure): one flat `store.list(prefix)` returns every key +
  size in a single streamed pass — no recursion. Progress ticks as keys arrive.
- SSH: `find <root> -type f -printf '%s\t%p\n'` over the exec channel, parsed
  into the same flat file tree the walk produces — orders of magnitude fewer
  round-trips than an SFTP walk. A non-zero exit with output (unreadable subdir)
  is still accepted; a truly empty failure falls back.
- Faro Agent: the same `find`, via the daemon's bounded Exec (gated by
  allowExec); denial/timeout/truncation fall back to the walk.

The chosen strategy shows as the header badge; a fallback records a short note
("exec disabled — used walk") surfaced next to it. diskscan_start now resolves
the Session and threads it through ScanManager::start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-clicking a treemap cell or a ranked-list row opens a context menu:

- Reveal / Open in file browser — a one-shot revealTarget signal on layoutStore
  that FileBrowser + DualPaneBrowser consume to point the active pane at the
  item's folder (closing the explorer).
- Copy path — to the clipboard.
- Delete — confirmed via the shared ConfirmModal, then pruned from the tree in
  place (subtracting the freed bytes from every ancestor) so the map + list
  update instantly without a rescan. The scan root can't be deleted from here.

Colour-by-type/depth and Rescan already shipped in Phase 2. "Remember last scan
per connection" is intentionally deferred — the entry point always carries the
current directory, so its marginal value doesn't justify a faro.db table yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tokio integration test walks an actual temp directory over LocalFs and folds
it into the aggregated size tree (the generic strategy every backend falls back
to), plus a parser test that `find`'s output produces the identical tree shape —
so the SSH/agent fast path and the walk agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Azure Blob and the aws/r2/b2 presets already shipped, so the plan was
stale. Reordered by current cost (native GCS is now a one-builder move
via object_store's gcp feature), added new backends (Box, pCloud, NFS
stretch, read-only HTTP source, WebDAV provider presets, more S3
presets), resequenced the OAuth tier by API friction (Dropbox first —
path-based), and synced ROADMAP Track B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phases 1–4 land: the scan engine + generic walk, the Canvas treemap explorer +
size list, the exec-find / object-flat fast paths, and the reveal/copy/delete
actions. Core scan behaviour is runtime-verified against a real filesystem and
the app boots clean; a GUI click-through on a live backend is the remaining
manual check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Wasabi, DigitalOcean Spaces, MinIO, Storj, Hetzner, Scaleway, Oracle
OCI, IBM COS, Supabase, and a generic self-hosted S3 preset to
S3_PROVIDER_PRESETS, each with an endpoint template + default region.
guessProvider now recognizes their endpoint patterns so an imported/edited
profile preselects the right button, and the provider grid renders every
preset from the table (vendor sub-label per row) instead of a hardcoded
three. No backend code: the object-store connect path already treats any
custom endpoint as path-style S3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GCS is the same one-builder move that shipped Azure: enable object_store's
gcp feature and add gcs_connect() using GoogleCloudStorageBuilder. The
bucket rides in profile.bucket like an S3 bucket / Azure container; the
service-account key comes from either a JSON file path (Key auth) or the
pasted JSON (Password auth). A GCS session is an ObjectSession, so browse,
transfer, sync, disk-usage, and rename/delete all work through the existing
ObjectFs + object transfer arms unchanged.

Frontend: add the gcs Protocol (default port 443, label GCS, object-store
group), a GcsSection in the New Connection editor with a file/paste key
toggle, and route gcs through every protocol switch (rail badge gs://,
bridge/import/search icons, mock capabilities).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A new RemoteFs impl over WebDAV (Nextcloud, ownCloud, Hetzner Storage Box,
and the generic long tail). One protocol, real directories, rename via MOVE,
no chmod; change detection uses the per-resource ETag with mtime+size as the
fallback for servers that omit getetag.

Backend:
- session/webdav.rs — WebdavSession (shared reqwest client, base URL carrying
  the DAV root, Basic/Bearer auth). Connect validates creds + URL with a
  depth-0 PROPFIND. url_for percent-encodes each path segment.
- remotefs/webdav.rs — WebdavFs: list_dir (PROPFIND depth 1), rename (MOVE,
  no-clobber), delete (DELETE), create_dir (MKCOL), capabilities. A namespace-
  prefix-agnostic multistatus parser + a dependency-free RFC1123 date parser.
- transfer.rs — streaming GET download (real progress) + streaming PUT upload;
  HEAD-based size/overwrite probes.
- Session::Webdav variant threaded through session connect/disconnect,
  fs_for_session (commands + transfer), and editor.rs edit-in-place.

Frontend: webdav Protocol (label WebDAV, HTTP :443, Globe icon), a
WebdavSection in the New Connection editor with provider presets
(Nextcloud/ownCloud/Storage Box/generic) that prefill the DAV URL template
and a Basic/Bearer auth toggle; rail address shows the server URL.

Verification: 5 parser unit tests (Nextcloud, Apache mod_dav, full-URL hrefs,
HTTP-date, etag cleaning) + a live end-to-end #[ignore] test exercising
connect -> MKCOL -> PUT -> PROPFIND -> GET -> MOVE -> DELETE through Faro's
own WebdavFs, run green against a local wsgidav server with Basic auth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phases 0 (S3 presets), 1 (native GCS), and 2 (WebDAV) are built and verified.
Note the Phase 3 SMB build-time finding: pavao/libsmbclient has no MSVC build
and the pure-Rust smb crate is immature, so SMB is deferred pending a viable
client + a NAS to verify against.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browse any static file server as a Faro connection. A new RemoteFs impl that
lists via autoindex and reads via GET; every mutation errors (read-only).

Backend:
- session/http.rs — HttpSession (shared reqwest client, optional Basic auth).
  Connect decides Listing vs DirectFile mode from the URL shape (trailing
  slash / a filename extension) so pasting a direct artifact URL works with no
  listing, and probes reachability + auth with a HEAD.
- remotefs/http.rs — HttpFs: list_dir parses nginx/Apache autoindex HTML
  (anchor-based, handles <pre> and <table>) or nginx JSON autoindex; DirectFile
  mode HEADs the single file. rename/delete/create_dir/chmod all return a
  read-only error. change_signal Etag (from the file's own ETag/Last-Modified).
- transfer.rs — streaming GET download; upload errors; HEAD-based size.
- Session::Http threaded through connect/disconnect, both fs_for_session
  copies, and editor.rs (download works, save errors).
- Shared webdav::parse_http_date (now pub(crate)) for Last-Modified / JSON mtime.

Frontend: http Protocol (label HTTP, read-only :443, Download icon), an
HttpSection with a URL field + optional Basic-auth toggle; rail address shows
the URL.

Verification: 5 parser unit tests (nginx HTML, Apache table with human sizes,
nginx JSON, size tokens) + a live #[ignore] end-to-end test run green against
python -m http.server in both listing and direct-file modes — list, GET
round-trip, and refused mutations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oauth.rs: a provider-agnostic OAuth 2.0 loopback + PKCE (S256) authorization-
code helper with no external OAuth crate — a fixed-port (:53682) local listener
catches the redirect, the code is exchanged against a configurable token
endpoint, and refresh is a second form POST. Endpoints are OAuthConfig fields so
tests can point them at a local mock. Tokens persist in the OS keychain via the
keyring crate (native backends only — no dbus dependency), never in profile JSON.

Verified: PKCE S256 matches the RFC 7636 reference vector; authorize-URL,
token-parsing, and expiry unit tests pass; a keychain write->read->delete
round-trip runs green against Windows Credential Manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dropbox API v2 is path-addressed, so it slots straight into the trait.

- session/dropbox.rs — DropboxSession: bearer-token RPC/content helpers with
  transparent token refresh (proactive on near-expiry + retry-once on 401),
  re-persisting refreshed tokens to the keychain. Endpoints fall back to env
  overrides so the flow can be pointed at a mock. account_label/exists/size
  helpers. Tokens loaded from the keychain at connect (errors clearly if the
  connection was never authorized).
- remotefs/dropbox.rs — DropboxFs: list_folder (+ /continue pagination),
  move_v2, delete_v2, create_folder_v2; rev as the change token; ISO-8601
  server_modified parsing. Faro↔Dropbox path mapping (/ ⇒ "").
- transfer.rs — streaming download (/2/files/download) + simple upload
  (/2/files/upload, overwrite; >150 MB refused with a clear message pending
  chunked upload_session); size/overwrite probes via get_metadata.
- Session::Dropbox threaded through connect/disconnect, both fs_for_session
  copies, and editor.rs edit-in-place.

Verified by unit tests (path mapping, ISO-8601, list_folder parsing incl.
deleted-tombstone drop). OAuth infra reused from Phase 5a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dropbox_authorize command runs the interactive OAuth flow (browser + loopback
redirect), stores tokens in the OS keychain keyed by profile id, and returns the
account label — mirroring agent pairing, so a cancelled flow leaves no saved
connection. delete_profile now clears a Dropbox profile's keychain tokens.

Frontend: dropbox Protocol (label Dropbox, Box icon, OAuth hint), a
DropboxSection with a 'Connect with Dropbox' button that authorizes then gates
Save on success and shows 'Connected as <account>'; rail address shows the
account. Typed ipc.dropboxAuthorize wrapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jhd3197 and others added 26 commits July 17, 2026 17:11
On-demand virtual folders (OneDrive-style placeholders). Adds the
"virtualfs" cargo feature (OFF by default; the windows crate + Cloud
Filter FFI only compile with --features virtualfs) and a cross-platform
VirtualFs subsystem mirroring agent_host.rs: persisted registered sync
roots, orphan-safe reconcile (pure, unit-tested), a Hydrator abstraction
that reuses the per-backend TransferManager download path, and
virtualfs_supported/status/free_up_space commands. The native provider
is behind a platform module — unsupported.rs is an inert stub so the
default build compiles and an on-demand pair degrades to a clear "not
available" rather than an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a per-pair SyncMode to folder sync (default Mirror, so existing
pairs are untouched). OnDemand pairs skip the eager reconcile loop and
are handed to the VirtualFs provider instead: auto_start, upsert,
remove, and set_enabled now all call reconcile_virtualfs, which pushes
the enabled on-demand pair set into VirtualFs so it registers new roots
and unregisters orphaned ones in one place. mode rides along in PairView
via the existing flatten, so the UI can offer the toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native on-demand provider, built on the safe cloud-filter 0.0.6
wrapper over cldapi.dll (swapped in for the raw windows crate to keep
the unsafe surface tiny — this code can only be verified manually in
Explorer). Per on-demand pair: register a per-pair sync root
(Faro.<pair-id> + user SID, so unregister is rebuildable from the id
alone for exact orphan cleanup), eagerly seed top-level placeholders
from a RemoteFs list_dir walk, then connect a SyncFilter whose

  - fetch_data hydrates on access by downloading through the shared
    TransferManager path and streaming it to the OS in 4 KiB-aligned
    chunks (callbacks run on OS threads and bridge to Faro's async
    backends via a captured tokio Handle + block_on),
  - fetch_placeholders lazily populates subdirectories on expand,
  - dehydrate allows "free up space" (mark_pin Unpinned over the tree),
  - delete/rename stay local — an on-demand folder is a live view, never
    mutating the backend.

Path blobs are validated against the pair's remote root before any
backend touch. Feature-gated (--features virtualfs); the default build
is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frontend for on-demand virtual folders. Adds the SyncMode type + mode
field on SyncPair, the VirtualFsRootStatus type, and typed IPC wrappers
(virtualFsSupported / virtualFsStatus / virtualFsFreeUpSpace). The sync
pair form offers a Mirror | On-demand toggle — shown only where it works
(virtualFsSupported, i.e. a Windows virtualfs build) — and hides the
mirror-only direction/strategy/exclude/poll fields when on-demand is
picked. Each on-demand pair row shows a cloud badge, a "hydrates on
open" status, and a Free-up-space action in place of Sync-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…safe)

A runtime round-trip test caught a real blocker: cloud-filter registers
sync roots through the WinRT StorageProviderSyncRootManager, which
requires package identity — so from unpackaged Faro (Tauri/NSIS) Register
returned Ok but silently registered nothing (GetSyncRootInformationForId
→ NOT_FOUND, active_roots showed only the real OneDrive).

Fix: register/unregister via the Win32 CfRegisterSyncRoot /
CfUnregisterSyncRoot (the API the plan specified — no package identity
needed), keyed by path, and keep cloud-filter only for connect +
placeholders + hydration callbacks (its Session::connect already uses the
path-based Win32 CfConnectSyncRoot, so it composes cleanly). Policies
default to Partial hydration + Partial population = fully on-demand.

The register→CfGetSyncRootInfoByPath→unregister round-trip now passes on
real cldapi from an unpackaged test process, verifying the orphan-safety
primitive reconcile depends on. Full hydration still needs manual
Explorer verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erify left)

Update the plan doc status from "DESIGNED, NOT BUILT" to reflect the
feature-flagged Windows Cloud Filter provider, document the Win32-vs-WinRT
package-identity finding, and record what's runtime-verified vs left for
manual Explorer testing. Mirror the same in ROADMAP (Plan 9 row + Track C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
faro-agentd's exec() clamped a caller timeout to a private 10-min cap, so a
bridge-accepted `--timeout-ms 900000` was silently shortened on paired-agent
targets. Move EXEC_TIMEOUT_MS_MIN/MAX into faro-agent-proto and clamp against
it in both the daemon and the bridge, so the documented 15-min ceiling holds
on every target. Add a daemon unit test proving 12–15 min timeouts now pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app and faro-cli ship as separate downloads, so the CLI silently lags
after an app update — which is what produced the session's cryptic
`unexpected argument '--timeout-ms'`. The bridge already publishes its app
version in agent-endpoint.json; faro-cli now reads it, compares to its own
build version, and prints a one-line stderr staleness warning before running
any `agent`/`skill` command. Best-effort semver compare — absent/unparseable
versions suppress the check rather than false-alarm. Unit-tested and verified
at runtime with a crafted endpoint file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
faro-cli agent upload/upload-dir/download now detect a Windows-drive-prefixed
remote path (C:\… / C:/…) against a non-Windows server — the tell-tale of MSYS
path conversion rewriting a POSIX /var/www argument before faro-cli sees it —
and refuse with an actionable hint (MSYS_NO_PATHCONV=1, leading //, or
`agent write`) instead of silently uploading to a nonsense directory. The
/info OS lookup only runs on the drive-prefixed error path, so the common good
path pays nothing. Escape documented in docs/remote-agent.md; decision logic
unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stics)

Adds `faro-cli agent script <server> <file>` and `agent exec --file/--stdin`:
the CLI reads the script's raw bytes locally and ships them as an opaque
base64 payload to a new bridge route `/exec_script`, which decodes and runs
them verbatim in the target's shell — `base64 -d | sh` on SSH (the tracked
exec path already does this), `sh -c`/`powershell -Command` on an agent. Since
the user's local shell never re-parses the program, heredocs, nested quotes
and newlines survive. The session's breakage was really the CLI's
`command.join(" ")` on shell-split args, which this bypasses entirely.

Gated as an Exec but, like a Write, never auto-approved by the safe-read-only
heuristic (only allow-all) — a script is multi-statement. The approval prompt
shows a header + the script body (capped); the console/audit show a friendly
label, never the body. Also exposed as the MCP `faro_exec_script` tool so the
in-app AI gets the same affordance. exec_core_agent gained a label param.

Verified: daemon runs a multi-line PowerShell script with nested quotes
verbatim (exit 0, output intact); base64 wire round-trip is byte-exact;
approval summary survives multibyte truncation. Documented in remote-agent.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds `faro-cli agent write <server> <remote-path> [--from-file|--stdin|
--content] [--overwrite]`, the MCP `faro_write` tool, and a new bridge route
/write. SSH targets stream via SFTP create; Faro Agent targets via a single
truncating WriteChunk. This is the direct answer to "a dedicated edit command
would have let me drop a small debug script or patch on the server" — no
staging local file, no upload path to mangle. Content rides the wire as base64
so any bytes survive; refuses to overwrite an existing file unless asked.

Gated as a Write (never auto-approved except allow-all). The CLI path also runs
the Phase 3 MSYS mangling guard on the remote path. The agent write path is the
same WriteChunk round-trip the end-to-end harness verifies byte-exact over a
real Noise channel; the SSH path reuses the production upload's SFTP create.
Documented in remote-agent.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds `faro-cli self-update [--tag <v>] [--check]`: resolves the release asset
matching this OS/arch (the same faro-cli-<os>-<arch> assets the release
workflow ships), downloads it from GitHub over HTTPS, and swaps it in. The swap
writes the new bytes to a sibling temp file, then renames — atomically over the
target on Unix, and on Windows (where a running exe can't be overwritten) moves
the current exe aside and the new one in, rolling back on failure. --check only
compares this binary's version to GitHub's latest. Enables ureq's rustls TLS
(previously localhost-http only) so the CLI can reach GitHub.

Verified live: `self-update --check` reports v1.3.19 → latest v1.3.21 over
HTTPS; a full self-update on a temp copy downloaded and swapped in the release
binary, which then reported v1.3.21. Swap mechanism + asset-URL building are
also unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erence

Adds a stateful `cli_updater` subsystem shaped like agent_host.rs: on launch it
locates the installed faro-cli (sidecar next to the app, else PATH via
where/which), reads its --version, and compares to the app version. Per the
persisted cliUpdate preference it prompts (ask, default), updates silently
(auto), or stays quiet (off). It emits "cli-updater://status"; updating
delegates to `faro-cli self-update` when the CLI is present, or downloads the
matching asset into the app data dir when it's missing.

Frontend: typed ipc wrappers + onCliUpdaterStatus listener, a cliUpdaterStore,
a non-blocking <CliUpdatePrompt> above the status bar (Update now / Always
update automatically / dismiss) that appears only when installed && stale &&
ask, and a Settings → faro-cli section (status card + Update/Check + the
ask/auto/off preference). The missing-CLI install affordance lives in Settings
rather than a global banner, to avoid nagging users who don't use the CLI.

Verified live: launched the real app with a stale CLI on PATH — the startup
check logged `installed=true cli_version=1.3.7 app_version=1.3.19 stale=true
mode=Ask`, exactly the state that raises the prompt. locate+version tested
against a real binary; tsc clean; cargo check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds `faro-cli fetch <url> [--profile <name>]`: performs an authenticated GET
by reusing a saved HTTP(S) profile's stored Basic-Auth credentials (Plan 5
Phase 4's HttpSession applies the header), so an agent can read a rendered page
on an auth-walled staging site instead of injecting debug logging. The profile
is matched by the URL's host, or chosen with --profile; the body goes to
stdout and a non-2xx status exits non-zero. Credentials are never echoed.

Verified end-to-end against a local Basic-Auth server: `fetch` returned the
protected page via the saved profile (both host-matched and explicit
--profile), with no password leaked to stderr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`faro-cli agent exec <server> --detach "<cmd>"` (and MCP `faro_exec` with
detach=true) launches the command server-side and returns a job id at once
instead of blocking on the exec timeout — retiring the manual
`nohup … & ; tail -f log` loop for multi-minute backfills/migrations.

SSH-first, pure convention (no protocol change): the command runs detached
via `setsid`/`nohup` into a per-job dir `~/.faro/jobs/<id>/{cmd,out,err,exit,
pid}`. `faro-cli agent job <server> <id>` (MCP `faro_job`) polls the captured
stdout/stderr (512 KiB cap each) and mirrors the exit code once finished;
`faro-cli agent jobs <server>` lists running/finished jobs. Launch base64s
the command so no quoting breaks the wrapper; the poll base64s out/err so no
marker collides with content; job ids are UUID-charset-validated before they
touch the shell path. Job dirs older than 7 days are pruned on next launch.

Gated as an Exec (never auto-approved except allow-all); poll/list are Reads.
Detached exec on a paired Faro Agent target returns a clear "not yet" error —
the ExecStart/Poll/Kill protocol arm is the documented next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the daemon-target half of background jobs so `--detach` / faro_job work
against a paired machine, not just SSH. New protocol ops (additive, no version
bump) ExecStart/ExecPoll/ExecKill; the daemon spawns the command, streams its
stdout/stderr into capped in-memory buffers, records the exit code, and answers
polls by job id. A shared JobStore (on the Daemon behind an Arc) means a job
started on one channel survives the controller re-dialing; finished jobs are
reaped on a TTL and the store is bounded. Killable via a oneshot to the waiter
task. The one gap versus the SSH arm's on-disk dir: a daemon restart forgets
in-flight jobs (a later poll returns not_found).

Wiring: op_exec_detached / op_job in bridge.rs now route agent targets through
the daemon (SSH path unchanged); faro_job accepts agent sessions (Exec, not
SshOnly); MCP schemas updated. Because the ops are additive, existing agent ops
still work against an older daemon — only detached-job requests fail, with a
clear "update faro-agentd" message.

Verified: JobStore unit tests (start/poll/kill on a real Windows child) + a new
end-to-end test drives pair → ExecStart → poll-to-exit → not_found over the real
Noise channel. Live run against the paired phone (needs the Android daemon
rebuilt from this tree) is left to the maintainer, per the sibling plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 background jobs now land on both arms (SSH job dir + paired-agent
ExecStart/Poll/Kill). Live SSH-box + phone-agent smoke tests remain the
maintainer's step, per the sibling plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-only

The agent arm now backs `--detach` / `agent job`, so drop the "SSH only" from
their help. `agent jobs` (list) stays SSH-only and keeps its note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The demo build's `invoke` mock resolved unhandled commands to null. Once the
status bar started reading `useSync().pairs` and calling `.filter` on it
unconditionally, the null return from `foldersync_list` crashed StatusBar on
boot and blanked the whole app (every screenshot came out black). Handle the
five `foldersync_*` commands the way the real backend does — return the pair
list — so the demo renders again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The editor was a tall 32rem single column with a 13-button protocol grid
stacked above the form — cramped now that the backend list keeps growing. Rework
it into a 52rem two-pane dialog like Settings / Agent Bridge: a grouped,
scrollable protocol rail on the left (Servers · Object storage · Web · Cloud
drives · Machine) and the form on the right, with the selected protocol's
label + port in the pane header. New backends slot into a group instead of
making the dialog taller.

Screenshots regenerated (all shots refreshed; new-connection.png shows the new
layout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The README still described eight backends and a file-manager-plus-sync feature
set; the app now spans thirteen backends and a lot more. Bring it current:

- Backends table rebuilt — S3-compatible (13 vendor presets), Azure Blob, GCS,
  WebDAV, read-only HTTP, the Dropbox/OneDrive/Google Drive/Box OAuth clouds,
  and Faro Agent, with capability notes.
- New sections: Explore/diff/search (Disk Usage Explorer, Directory Diff incl.
  remote↔remote, Fleet Search), continuous Folder Sync, and Fleet Skills.
- Highlights, CLI (diff/search/agent/skill/fetch/self-update), Architecture,
  Layout, and Roadmap updated to match what actually ships.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The disk-usage treemap is the standout new UI and had no shot. Teach the
demo/mock build to drive it: a fictional /var/www/api size tree + a done
ScanSnapshot in the mock data, the five diskscan_* commands wired in the invoke
mock, and the diskScanStore exposed on window.__demo so the capture script can
open the explorer straight into a finished scan. Adds docs/screenshots/
disk-usage.png (force-added past the docs/screenshots/*.png ignore, like the
other curated shots) and features it near the top of the README's Screenshots
section, expanded by default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an in-app SSH key generator to the New Connection editor's private-key
auth, so users never have to leave Faro (or open PuTTYgen) to create a key.

- New keys module (src-tauri/src/keys.rs): generate Ed25519 / RSA keypairs,
  write the private key in native OpenSSH format (encrypted with bcrypt-pbkdf +
  aes256-ctr when a passphrase is given) plus a .pub beside it, and return the
  public-key line ready for the server's ~/.ssh/authorized_keys. Uses the
  ssh-key crate rather than russh for writing: russh only emits PKCS#8 PEM,
  which OpenSSH itself refuses to load for Ed25519 ("error in libcrypto"). The
  format ssh-key writes is read back by both OpenSSH's ssh/ssh-keygen and
  russh's own load_secret_key (the SSH connect path), verified by tests.
- Commands: generate_ssh_key, ssh_public_key_for (copy the public half of an
  existing key without regenerating), ssh_key_defaults (suggest a free
  ~/.ssh/faro_ed25519 path).
- ProfileEditor: a "Generate new key…" panel (type, save location, optional
  passphrase) that points the connection at the new key and shows the public
  key with a Copy button + authorized_keys nudge; a "Copy public key" action
  and a Browse button for existing keys.
- Connect path now expands a leading ~ in a key path, so both generated
  absolute paths and stored ~/.ssh/... paths load (load_secret_key does not).

Tests cover the Ed25519 encrypted round-trip through the real connect-path
loader, RSA generation, and an interop check that the system ssh-keygen accepts
what Faro writes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `.marketing/` to `.gitignore` with a clarifying comment so local marketing drafts and channel notes stay out of version control, consistent with other local scratch directories.
# Conflicts:
#	src/components/ProfileEditor.tsx
Copilot AI review requested due to automatic review settings July 18, 2026 15:48

Copilot AI 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.

Pull request overview

This PR adds first-class, in-app SSH keypair generation and public-key derivation to streamline SFTP key-based auth setup, while also fixing ~-prefixed key paths during SSH connect by expanding them before loading.

Changes:

  • Added a new Rust keys module and Tauri commands to generate OpenSSH-format Ed25519/RSA keys and derive public keys/fingerprints.
  • Updated the SFTP key-auth UI in ProfileEditor with a generator flow, overwrite handling, and clipboard copy for the public key.
  • Fixed SSH key auth to expand ~/.ssh/... paths before russh_keys::load_secret_key.

Reviewed changes

Copilot reviewed 8 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/lib/types.ts Adds frontend types for key generation/derivation IPC payloads.
src/lib/ipc.ts Adds typed IPC wrappers for ssh-key defaults, generation, and public-key derivation.
src/components/ProfileEditor.tsx Replaces basic key-path inputs with a full key-auth section (generate/derive/copy).
src-tauri/src/session/mod.rs Expands ~ in stored key paths before loading secret keys.
src-tauri/src/lib.rs Registers the new keys module and command handlers.
src-tauri/src/keys.rs Implements OpenSSH-format key generation, public key derivation, tilde expansion, and tests.
src-tauri/src/commands.rs Exposes key-related Tauri commands (defaults/generate/derive).
src-tauri/Cargo.toml Adds direct ssh-key dependency for OpenSSH private-key output.
src-tauri/Cargo.lock Updates lockfile for the new dependency (but currently has workspace version mismatches).
.gitignore Ignores .marketing/ local scratch directory.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src-tauri/src/commands.rs
Comment on lines +82 to +85
/// Generate a new SSH keypair, write the private key (PKCS#8 PEM, encrypted when
/// a passphrase is given) plus a `.pub` beside it, and return the public-key line
/// to install on the server. Key generation is CPU-bound (RSA-4096 especially),
/// so it runs on the blocking pool rather than the async runtime.
@jhd3197
jhd3197 merged commit 672362b into main Jul 18, 2026
2 checks passed
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.

2 participants