Skip to content

Commit 25b0b04

Browse files
pmaxhoganclaude
andcommitted
fix(app,ui): M6 codex round-1 - account/OAuth lifecycle, live crypto + recovery-phrase reveal, dialog-token path validation + diagnostics
Fixes all 8 P1 + 3 P2 findings from .claude/codex-reviews/M6-20260624-011401.md. CI/Chaos were green but the wizard/account/source/crypto lifecycle had real end-to-end gaps the mocked unit tests did not exercise. Theme A - account / OAuth lifecycle: - A1: persist per-account BYO OAuth client creds in the keychain (ClientCredsStore) so refresh works after restart; load everywhere a RefreshingTokenSource is built; delete on remove_account. - A2: AppState.accounts behind a sync Mutex with insert/remove; assembly spawn_account hot-spawns the orchestrator after finish_add_account so the wizard's initial sync_now finds a live handle (no restart). - A3: reauth_account returns { sessionId, authUrl }; the UI completes re-consent onto the existing account (no duplicate) and hot-spawns it. - A4: only the frontend opens the consent URL (backend no longer double-opens). - A5: request userinfo scopes + fetch the real Google email/display name. Theme B - source / crypto / recovery-phrase: - B1: pick_drive_folder returns the concrete root id "root" so setup can select a destination (incl. My Drive root). - B2: KeystoreCryptoProvider resolves LIVE (refreshable source map on AccountHandle.crypto, refreshed by reconfigure_account); fail-closed preserved. - B3: the recovery phrase is a one-time RETURN VALUE on AddSourceResult, shown once via RecoveryPhraseReveal AFTER the source/key exists, with Finish/Done gated on an explicit ack; never an unrestorable encrypted backup. Theme C - settings / diagnostics / path security: - C1: backend-owned native dialogs (pick_folder_dialog / pick_save_zip_dialog) mint one-shot path tokens (SPEC s11.6.1); add_source + export validate the token -> path binding and reject any untrusted path. - C2: export writes a real .zip FILE at the save-dialog path. - C3: diagnostic bundle now includes activity_last_30d.csv, logs/, crashes/, the redaction pipeline, and the real PRAGMA user_version (new StateRepo::schema_version). Tests EXERCISE each fix: backend #[cfg(test)] (crypto refresh, dialog tokens, client-creds round-trip, userinfo parse, activity CSV redaction, schema version), src-tauri/tests/ipc_path_validation.rs (SPEC s11.6.1), and the vitest wizard walk completes end-to-end against the fake remote (root selectable, running-orchestrator sync_now, phrase-gated Finish, reauth sequence). design/CODEX_NOTES.md M6 section records the per-finding table + the Playwright-deferred-to-local note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CyiRqk2DVwmJjEu5gcD1m
1 parent 80c2452 commit 25b0b04

34 files changed

Lines changed: 2218 additions & 356 deletions

crates/driven-core/src/orchestrator.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,6 +2096,9 @@ mod tests {
20962096
async fn delete_activity_by_source(&self, _source: SourceId) -> anyhow::Result<u64> {
20972097
unimplemented!()
20982098
}
2099+
async fn schema_version(&self) -> anyhow::Result<i64> {
2100+
Ok(0)
2101+
}
20992102
async fn get_setting(&self, key: &str) -> anyhow::Result<Option<serde_json::Value>> {
21002103
Ok(self.settings.lock().unwrap().get(key).cloned())
21012104
}

crates/driven-core/src/planner.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,9 @@ mod tests {
346346
async fn delete_activity_by_source(&self, _source: SourceId) -> Result<u64> {
347347
unimplemented!()
348348
}
349+
async fn schema_version(&self) -> Result<i64> {
350+
unimplemented!()
351+
}
349352
async fn get_setting(&self, _key: &str) -> Result<Option<serde_json::Value>> {
350353
unimplemented!()
351354
}

crates/driven-core/src/scanner.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,9 @@ mod tests {
784784
async fn delete_activity_by_source(&self, _source: SourceId) -> anyhow::Result<u64> {
785785
unimplemented!()
786786
}
787+
async fn schema_version(&self) -> anyhow::Result<i64> {
788+
unimplemented!()
789+
}
787790
async fn get_setting(&self, _key: &str) -> anyhow::Result<Option<serde_json::Value>> {
788791
unimplemented!()
789792
}

crates/driven-core/src/state/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,12 @@ pub trait StateRepo: Send + Sync {
550550

551551
// --- settings -----------------------------------------------------------
552552

553+
/// The schema version recorded in SQLite's `PRAGMA user_version`
554+
/// (SPEC s18 diagnostic bundle `schema.txt`). Exposed on the object-safe
555+
/// trait so the diagnostic-bundle command (which holds only `dyn StateRepo`)
556+
/// can record the REAL schema version rather than "not exposed".
557+
async fn schema_version(&self) -> Result<i64>;
558+
553559
/// Reads a setting value (SPEC s22). Returns `None` if the key is
554560
/// absent. Values are JSON-typed per the schema's TEXT column.
555561
async fn get_setting(&self, key: &str) -> Result<Option<serde_json::Value>>;

crates/driven-core/src/state/sqlite.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,16 @@ impl StateRepo for SqliteStateRepo {
13481348
.await
13491349
}
13501350

1351+
async fn schema_version(&self) -> Result<i64> {
1352+
// PRAGMA returns a non-standard row shape the `query!` macro cannot
1353+
// describe, so use the dynamic query API (mirrors the wal_checkpoint
1354+
// call). `user_version` is an i64-typed pragma column.
1355+
let row: (i64,) = sqlx::query_as("PRAGMA user_version;")
1356+
.fetch_one(&self.pool)
1357+
.await?;
1358+
Ok(row.0)
1359+
}
1360+
13511361
// --- settings -----------------------------------------------------------
13521362

13531363
async fn get_setting(&self, key: &str) -> Result<Option<Value>> {

crates/driven-drive/src/google/oauth.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
4646
/// The Drive scope Driven requests (full Drive access; SPEC s4).
4747
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive";
4848

49+
/// The OpenID userinfo email scope (A5): lets Driven read the account's Google
50+
/// email via the userinfo endpoint so the Accounts UI / needs_reauth banner show
51+
/// the real address rather than a placeholder label.
52+
const USERINFO_EMAIL_SCOPE: &str = "https://www.googleapis.com/auth/userinfo.email";
53+
54+
/// The OpenID userinfo profile scope (A5): lets Driven read the account's
55+
/// display name from the userinfo endpoint.
56+
const USERINFO_PROFILE_SCOPE: &str = "https://www.googleapis.com/auth/userinfo.profile";
57+
4958
/// Connect timeout for the code-exchange client (DESIGN s5.8.4; codex V-A1).
5059
const EXCHANGE_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
5160

@@ -138,6 +147,10 @@ pub async fn run_pkce_loopback_flow(
138147
let (auth_url, csrf_state) = client
139148
.authorize_url(CsrfToken::new_random)
140149
.add_scope(Scope::new(DRIVE_SCOPE.to_string()))
150+
// A5: request the userinfo email + profile scopes so the account's real
151+
// Google email + display name can be fetched from the userinfo endpoint.
152+
.add_scope(Scope::new(USERINFO_EMAIL_SCOPE.to_string()))
153+
.add_scope(Scope::new(USERINFO_PROFILE_SCOPE.to_string()))
141154
// `access_type=offline` + `prompt=consent` force Google to mint a
142155
// refresh token (otherwise re-auth yields only an access token).
143156
.add_extra_param("access_type", "offline")

crates/driven-drive/src/google/token_store.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ const TARGET: &str = "driven::drive::token";
4747
/// keyring "service" namespace for Driven Google refresh tokens (SPEC s4.1).
4848
const KEYRING_SERVICE: &str = "driven.google.refresh_token";
4949

50+
/// keyring "service" namespace for Driven per-account BYO OAuth client
51+
/// credentials (A1 / DESIGN s6.1). A refresh token is bound to the OAuth client
52+
/// that minted it, so the account's `client_id` + `client_secret` MUST persist
53+
/// alongside the refresh token - otherwise a restart falls back to the
54+
/// env/default client and every BYO-client refresh fails (`invalid_client`).
55+
const KEYRING_CLIENT_CREDS_SERVICE: &str = "driven.google.client_creds";
56+
5057
/// Google's OAuth token endpoint (SPEC s4.1 refresh path).
5158
const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
5259

@@ -114,6 +121,91 @@ impl KeyringTokenStore {
114121
}
115122
}
116123

124+
/// A persisted per-account BYO OAuth client credential pair (A1).
125+
///
126+
/// `client_secret` is empty for a PKCE installed-app client (no real secret).
127+
#[derive(Debug, Clone, PartialEq, Eq)]
128+
pub struct ClientCreds {
129+
/// The OAuth client id that minted the account's refresh token.
130+
pub client_id: String,
131+
/// The OAuth client secret (empty for a PKCE installed-app client).
132+
pub client_secret: String,
133+
}
134+
135+
/// Keychain wrapper for an account's BYO OAuth client credentials (A1 / DESIGN
136+
/// s6.1). One entry per account, in the [`KEYRING_CLIENT_CREDS_SERVICE`]
137+
/// namespace, keyed by account id. The two fields are stored as a single
138+
/// newline-separated record (`client_id\nclient_secret`) so one keychain entry
139+
/// holds the pair. The secret is NEVER logged.
140+
pub struct ClientCredsStore {
141+
account: String,
142+
}
143+
144+
impl ClientCredsStore {
145+
/// Builds a client-creds store scoped to `account` (the keychain lookup key
146+
/// within the Driven client-creds namespace).
147+
pub fn new(account: impl Into<String>) -> Self {
148+
Self {
149+
account: account.into(),
150+
}
151+
}
152+
153+
/// Opens the keychain entry for this account's client creds.
154+
fn entry(&self) -> anyhow::Result<Entry> {
155+
Entry::new(KEYRING_CLIENT_CREDS_SERVICE, &self.account)
156+
.map_err(|e| anyhow::anyhow!("keychain: failed to open client-creds entry: {e}"))
157+
}
158+
159+
/// Persists `creds` for this account (A1). Stored as
160+
/// `client_id\nclient_secret` in one keychain entry.
161+
pub fn store(&self, creds: &ClientCreds) -> anyhow::Result<()> {
162+
let record = encode_client_creds(creds);
163+
self.entry()?
164+
.set_password(&record)
165+
.map_err(|e| anyhow::anyhow!("keychain: failed to store client creds: {e}"))
166+
}
167+
168+
/// Loads the stored client creds, or `None` if the account never persisted
169+
/// any (a default/env-client account). A `NoEntry` maps to `Ok(None)`.
170+
pub fn load(&self) -> anyhow::Result<Option<ClientCreds>> {
171+
match self.entry()?.get_password() {
172+
Ok(record) => Ok(Some(decode_client_creds(&record))),
173+
Err(keyring::Error::NoEntry) => Ok(None),
174+
Err(e) => Err(anyhow::anyhow!(
175+
"keychain: failed to load client creds: {e}"
176+
)),
177+
}
178+
}
179+
180+
/// Deletes the stored client creds (e.g. on account removal). Absent entry
181+
/// is a no-op.
182+
pub fn delete(&self) -> anyhow::Result<()> {
183+
map_delete_result(self.entry()?.delete_credential())
184+
}
185+
}
186+
187+
/// Encode a [`ClientCreds`] pair as the single keychain record
188+
/// `client_id\nclient_secret`. Pure so it is unit-testable without a keychain.
189+
fn encode_client_creds(creds: &ClientCreds) -> String {
190+
format!("{}\n{}", creds.client_id, creds.client_secret)
191+
}
192+
193+
/// Decode a keychain record (`client_id\nclient_secret`) into [`ClientCreds`].
194+
/// A record with no newline is treated as a bare client id (empty secret). Pure
195+
/// so it is unit-testable without a keychain.
196+
fn decode_client_creds(record: &str) -> ClientCreds {
197+
match record.split_once('\n') {
198+
Some((id, secret)) => ClientCreds {
199+
client_id: id.to_string(),
200+
client_secret: secret.to_string(),
201+
},
202+
None => ClientCreds {
203+
client_id: record.to_string(),
204+
client_secret: String::new(),
205+
},
206+
}
207+
}
208+
117209
/// Maps a `keyring` `get_password` result to the load-token domain result:
118210
/// `Ok(pw) -> Ok(Some(pw))`, `Err(NoEntry) -> Ok(None)`, other `Err -> Err`.
119211
/// Pure so it is unit-testable without a keychain.
@@ -401,6 +493,38 @@ mod tests {
401493
assert_eq!(r, Some("tok".to_string()));
402494
}
403495

496+
#[test]
497+
fn client_creds_encode_decode_round_trips() {
498+
// A1: a BYO client id + secret round-trips through the single keychain
499+
// record (`client_id\nclient_secret`).
500+
let creds = ClientCreds {
501+
client_id: "byo-id.apps.googleusercontent.com".to_string(),
502+
client_secret: "byo-secret".to_string(),
503+
};
504+
let record = encode_client_creds(&creds);
505+
assert_eq!(decode_client_creds(&record), creds);
506+
}
507+
508+
#[test]
509+
fn client_creds_decode_tolerates_pkce_empty_secret() {
510+
// A PKCE installed-app client has an empty secret; the record is
511+
// `id\n` and decodes to an empty secret.
512+
let creds = ClientCreds {
513+
client_id: "pkce-id".to_string(),
514+
client_secret: String::new(),
515+
};
516+
let record = encode_client_creds(&creds);
517+
assert_eq!(decode_client_creds(&record), creds);
518+
// A bare record with no newline is treated as a client id, empty secret.
519+
assert_eq!(
520+
decode_client_creds("bare-id"),
521+
ClientCreds {
522+
client_id: "bare-id".to_string(),
523+
client_secret: String::new(),
524+
}
525+
);
526+
}
527+
404528
#[test]
405529
fn map_load_no_entry_is_none() {
406530
let r = map_load_result(Err(keyring::Error::NoEntry)).unwrap();

design/CODEX_NOTES.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,3 +729,35 @@ yet. No deferral-by-typed-error was needed: both M6 commands have real bodies.
729729
Rust `preview_exclusions(req: ExclusionPreviewRequest)` signature). The test
730730
assertion was corrected to the real `{ req: { localPath } }` contract (a
731731
contract fix, not a weakening).
732+
733+
## M6 codex review round-1 fixes (8 P1 + 3 P2)
734+
735+
Source review `.claude/codex-reviews/M6-20260624-011401.md` (baseline 3af8fc8,
736+
M6 @ 80c2452): CI + Chaos were GREEN but the wizard/account/source/crypto
737+
lifecycle had real end-to-end gaps the mocked unit tests did not exercise. All
738+
11 findings fixed; new tests EXERCISE each fix (backend `#[cfg(test)]` +
739+
`src-tauri/tests/ipc_path_validation.rs` + vitest the wizard now completes
740+
end-to-end against the fake remote).
741+
742+
| Finding | What was broken | How it was fixed |
743+
|---|---|---|
744+
| P1-1 (B1) | Setup could not pick a Drive destination - `pick_drive_folder` echoed `current_folder_id: None` at root, so `setup.driveFolderId` was never set. | `pick_drive_folder` now resolves `None` -> the concrete Drive root alias `"root"` AND echoes it back as `current_folder_id`, so the user can select the current folder (incl. My Drive root). `add_source` accepts `"root"`. Test: `pick_drive_folder` root-listing mock + the wizard walk select the root id. |
745+
| P1-2 (A2) | A newly added account had no running orchestrator until restart, so the wizard's initial `sync_now(sourceId)` failed "no running orchestrator". | `AppState.accounts` moved behind a sync `Mutex<HashMap<_, Arc<AccountHandle>>>` with `insert_account`/`remove_account_handle`; assembly's per-account build factored into `assembly::spawn_account(app, &AppState, id)`, called by `finish_add_account` to hot-spawn + insert the handle (mirroring the M5 no-orphan drain - a prior handle is shut down first). Tests: `dialog_token`/handle bookkeeping + the vitest wizard walk hits a running-orchestrator mock for `sync_now`. |
746+
| P1-3 (A1) | BYO `client_id`/`client_secret` lived only in the in-memory wizard session; only the refresh token persisted, so after restart refresh fell back to env/default creds and FAILED for every BYO account (silent broken-account data loss). | New `ClientCredsStore` (keychain namespace `driven.google.client_creds`) persists the per-account client creds on `finish_add_account`; loaded everywhere a `RefreshingTokenSource` is built (`assembly::resolve_account_oauth_creds` used by boot `build_remote` + `pick_drive_folder` + reauth); deleted on `remove_account`. Secret never logged. Tests: `ClientCreds` encode/decode round-trip. |
747+
| P1-4 (A3) | Reauth created a hidden session and expected `finish_add_account`, but the UI only received `authUrl` and never the session id, so reauth never completed. | `reauth_account` now returns `ReauthSession { sessionId, authUrl }` (seeded with the account's stored client creds, A1); the UI opens the URL, listens `oauth:complete`, then `completeReauth(sessionId)` -> `finish_add_account` re-stores the new token onto the EXISTING account (no duplicate) + flips it back to `ok` + hot-spawns it. Tests: accounts-store `reauth` + `completeReauth`. |
748+
| P1-5 (B3) | The BIP39 recovery phrase was emitted as a transient event the UI never subscribed to; setup rendered the reveal BEFORE the source (empty phrase) and the confirm checkbox could be ticked with no phrase shown - so the app could create ENCRYPTED BACKUPS THE USER CAN NEVER RESTORE. | The phrase is now a ONE-TIME RETURN VALUE: `add_source` returns `AddSourceResult { source, recoveryPhrase }` (Some only when this opt-in generated the master key). `ensure_master_key` encodes the phrase BEFORE stamping the row and HARD-ERRORS (rolling back the key) if it cannot encode - never an unrestorable source. The UI shows the phrase via `RecoveryPhraseReveal` AFTER the source/key exists (setup confirm step; add-source a post-confirm reveal step) and gates Finish/Done on an explicit ack that is only enableable once a real phrase was displayed. Tests: store + vitest assert phrase returned, displayed, Finish disabled until acked. |
749+
| P1-6 (B2) | The crypto provider snapshotted source rows at assembly; `reconfigure_account` only updated orchestrator config. So an encrypted source added/toggled while running failed CLOSED (no row -> Unavailable) until restart. | `KeystoreCryptoProvider.sources` moved behind a `Mutex` with `refresh(sources)` that swaps the live map AND invalidates cache entries whose crypto fields changed/vanished; the provider Arc is held on `AccountHandle.crypto`, and `reconfigure_account` reads the account's current rows and refreshes it after every source add/update/remove. Fail-closed preserved (missing key -> Unavailable, never plaintext). Tests: refresh picks up a new encrypted source (was unknown->Plaintext, now Unavailable), toggles invalidate cache, removal drops to Plaintext. |
750+
| P1-7 (C1) | SPEC s11.6.1 requires dialog-derived paths; the impl took raw webview strings and fabricated a token from the untrusted parent. | The BACKEND now OWNS the dialogs: `pick_folder_dialog` / `pick_save_zip_dialog` (tauri-plugin-dialog Rust API via a oneshot) return `{ path, token }`; `AppState` holds a one-shot, TTL-bounded `token -> path` binding (`mint_dialog_token`/`take_dialog_token`). `add_source` takes `localPathToken` and `export_diagnostic_bundle` takes `token`; each resolves the path from the token (single-use) and REJECTS a path with no matching token, then runs `validate_writable_dest` (canonicalize / no-dotdot / no-symlink-leaf / confine-to-dialog-root / atomic). Frontend calls the backend dialogs. Tests: `src-tauri/tests/ipc_path_validation.rs` (traversal, symlink-at-leaf, non-existent parent, outside-root reject, valid) + `dialog_token` single-use/TTL. |
751+
| P1-8 (C2) | About asked for a DIRECTORY and passed it as `dest`; the backend then renamed a temp ZIP over the directory path -> always failed. | `pick_save_zip_dialog` returns a concrete `.zip` FILE path (suggested name + zip filter); `export_diagnostic_bundle` resolves it from the token and `atomic_write`s the ZIP AT that file. Test: the path-validation IT writes + reads back a real archive at the confined dest; About uses `pickSaveZipDialog`. |
752+
| P2-1 (C3) | The diagnostic bundle omitted `activity_last_30d.csv`, `logs/`, `crashes/`, and wrote "user_version not exposed". | Added `StateRepo::schema_version()` (real `PRAGMA user_version`); `build_diagnostic_zip` now adds `activity_last_30d.csv` (30-day activity, message+source hashed), `logs/` + `crashes/` from `<config>/driven/logs` through a redaction pipeline (`redact_log_text`: tokens -> `<token-redacted>`, paths -> `<path:hash>`, emails -> `<email:hash>`, drive-id-shaped -> `<fileid:hash>`), and the real `user_version`. Tests: schema summary has real `user_version`, activity CSV header + redacts message, redaction-pipeline unit tests. |
753+
| P2-2 (A4) | The consent URL was opened twice (backend `start_oauth_signin` AND frontend). | The backend opener closure now ONLY captures the URL for the return value (no `open_system_browser`); the FRONTEND is the single owner that opens it (add-account + reauth). |
754+
| P2-3 (A5) | Account email was a user label / `account-<id>`, not the Google email. | OAuth now requests the `userinfo.email`+`userinfo.profile` scopes; `finish_add_account` fetches `oauth2/v3/userinfo` (text + serde_json, no `json` reqwest feature) with the fresh access token and persists the real email + display name (fallback to a label on failure, never a fabricated address). Tests: userinfo parse (with + without name). |
755+
756+
### Playwright deferred-to-local (CI uses vitest for the wizard walk)
757+
758+
SPEC's end-to-end wizard coverage is exercised in CI by the vitest jsdom walk
759+
(`setup-wizard.test.ts` drives welcome -> credentials -> source -> encryption ->
760+
confirm against the fake backend, including the B3 phrase-gated Finish and the
761+
C1 backend folder dialog). A real Playwright/WebDriver run against the built
762+
Tauri app is deferred to a local pre-release check (no headless Tauri WebDriver
763+
in the Windows-only PR gate); the vitest walk is the CI proxy.

0 commit comments

Comments
 (0)