diff --git a/Cargo.lock b/Cargo.lock index a096db6b..1232e214 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2242,7 +2242,7 @@ dependencies = [ [[package]] name = "harness-gateway-client" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "async-trait", "bytes", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "harness-plugin-loader" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "libloading 0.8.9", "runvil-plugin", @@ -2269,7 +2269,7 @@ dependencies = [ [[package]] name = "harness-runner" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "futures-util", "rand 0.9.5", @@ -4064,7 +4064,7 @@ dependencies = [ [[package]] name = "plugin-mcp" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "reqwest", "rmcp", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "plugin-web" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "async-trait", "encoding_rs", @@ -4734,7 +4734,7 @@ dependencies = [ [[package]] name = "runvil" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "harness-plugin-loader", "harness-runner", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "runvil-engine" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "runvil-lua", "runvil-model-client", @@ -4758,7 +4758,7 @@ dependencies = [ [[package]] name = "runvil-lua" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "mlua", "runvil-markdown", @@ -4772,7 +4772,7 @@ dependencies = [ [[package]] name = "runvil-markdown" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "pulldown-cmark", "serde_json", @@ -4782,7 +4782,7 @@ dependencies = [ [[package]] name = "runvil-model-client" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "runvil-types", "serde", @@ -4792,7 +4792,7 @@ dependencies = [ [[package]] name = "runvil-plugin" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "runvil-types", "runvil-vfs", @@ -4815,7 +4815,7 @@ source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=03a9fa37d2fe6 [[package]] name = "runvil-prompt" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "pulldown-cmark", "runvil-lua", @@ -4827,7 +4827,7 @@ dependencies = [ [[package]] name = "runvil-runtime" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "mlua", "regex", @@ -4845,7 +4845,7 @@ dependencies = [ [[package]] name = "runvil-types" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" dependencies = [ "serde", "serde_json", @@ -4855,7 +4855,7 @@ dependencies = [ [[package]] name = "runvil-vfs" version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=7147e709f603b81786fb2d702bf50c4d9a88d62b#7147e709f603b81786fb2d702bf50c4d9a88d62b" +source = "git+https://github.com/cppalliance/runvil-harness?rev=3376b268a2c077bb6dba3d315a7fae442f75d296#3376b268a2c077bb6dba3d315a7fae442f75d296" [[package]] name = "rust-embed" @@ -7969,6 +7969,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", + "tokio", "url", "webkit2gtk", "webview2-com", @@ -8157,6 +8158,7 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", "tower", "tracing", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e4cfdb31..9892d7be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ repository = "https://github.com/cppalliance/runvil-workshop" # in. All four name one commit, because two commits would be two copies # of every type the crates share. `version` beside `git` keeps cargo-deny's # wildcard check quiet. -runvil = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" } -harness-gateway-client = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" } -plugin-web = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" } -plugin-mcp = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "7147e709f603b81786fb2d702bf50c4d9a88d62b" } +runvil = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "3376b268a2c077bb6dba3d315a7fae442f75d296" } +harness-gateway-client = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "3376b268a2c077bb6dba3d315a7fae442f75d296" } +plugin-web = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "3376b268a2c077bb6dba3d315a7fae442f75d296" } +plugin-mcp = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "3376b268a2c077bb6dba3d315a7fae442f75d296" } # The safe layer over the C Plugin contract, version 0.4, from its own # repository, pinned by commit. Only the library Plugin crates under # `crates/workshop/` name it, for `export_plugin!`. The commit is the one diff --git a/crates/workshop/desktop/Cargo.toml b/crates/workshop/desktop/Cargo.toml index beba53e7..a177dfb0 100644 --- a/crates/workshop/desktop/Cargo.toml +++ b/crates/workshop/desktop/Cargo.toml @@ -59,6 +59,7 @@ default = [] [dev-dependencies] tempfile.workspace = true +tokio.workspace = true workshop-server-api = { workspace = true, features = ["test-fixtures"] } [lints] diff --git a/crates/workshop/desktop/build.rs b/crates/workshop/desktop/build.rs index d2200271..4dbb22c0 100644 --- a/crates/workshop/desktop/build.rs +++ b/crates/workshop/desktop/build.rs @@ -33,7 +33,16 @@ const GATEWAY_SIDECAR_VARIABLE: &str = "RUNVIL_GATEWAY_SIDECAR"; fn main() -> Result<(), Box> { install_gateway_sidecar()?; tauri_build::try_build(tauri_build::Attributes::new().app_manifest( - tauri_build::AppManifest::new().commands(&["desktop_update_supported", "quit"]), + tauri_build::AppManifest::new().commands(&[ + "desktop_update_supported", + "quit", + "chatgpt_bind_capability", + "chatgpt_status", + "chatgpt_sign_in", + "chatgpt_sign_out", + "chatgpt_reset", + "chatgpt_enable", + ]), ))?; Ok(()) } diff --git a/crates/workshop/desktop/permissions/autogenerated/chatgpt_bind_capability.toml b/crates/workshop/desktop/permissions/autogenerated/chatgpt_bind_capability.toml new file mode 100644 index 00000000..69fed33a --- /dev/null +++ b/crates/workshop/desktop/permissions/autogenerated/chatgpt_bind_capability.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-chatgpt-bind-capability" +description = "Enables the chatgpt_bind_capability command without any pre-configured scope." +commands.allow = ["chatgpt_bind_capability"] + +[[permission]] +identifier = "deny-chatgpt-bind-capability" +description = "Denies the chatgpt_bind_capability command without any pre-configured scope." +commands.deny = ["chatgpt_bind_capability"] diff --git a/crates/workshop/desktop/permissions/autogenerated/chatgpt_enable.toml b/crates/workshop/desktop/permissions/autogenerated/chatgpt_enable.toml new file mode 100644 index 00000000..94fd699c --- /dev/null +++ b/crates/workshop/desktop/permissions/autogenerated/chatgpt_enable.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-chatgpt-enable" +description = "Enables the chatgpt_enable command without any pre-configured scope." +commands.allow = ["chatgpt_enable"] + +[[permission]] +identifier = "deny-chatgpt-enable" +description = "Denies the chatgpt_enable command without any pre-configured scope." +commands.deny = ["chatgpt_enable"] diff --git a/crates/workshop/desktop/permissions/autogenerated/chatgpt_reset.toml b/crates/workshop/desktop/permissions/autogenerated/chatgpt_reset.toml new file mode 100644 index 00000000..47c29d44 --- /dev/null +++ b/crates/workshop/desktop/permissions/autogenerated/chatgpt_reset.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-chatgpt-reset" +description = "Enables the chatgpt_reset command without any pre-configured scope." +commands.allow = ["chatgpt_reset"] + +[[permission]] +identifier = "deny-chatgpt-reset" +description = "Denies the chatgpt_reset command without any pre-configured scope." +commands.deny = ["chatgpt_reset"] diff --git a/crates/workshop/desktop/permissions/autogenerated/chatgpt_sign_in.toml b/crates/workshop/desktop/permissions/autogenerated/chatgpt_sign_in.toml new file mode 100644 index 00000000..1c99a78d --- /dev/null +++ b/crates/workshop/desktop/permissions/autogenerated/chatgpt_sign_in.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-chatgpt-sign-in" +description = "Enables the chatgpt_sign_in command without any pre-configured scope." +commands.allow = ["chatgpt_sign_in"] + +[[permission]] +identifier = "deny-chatgpt-sign-in" +description = "Denies the chatgpt_sign_in command without any pre-configured scope." +commands.deny = ["chatgpt_sign_in"] diff --git a/crates/workshop/desktop/permissions/autogenerated/chatgpt_sign_out.toml b/crates/workshop/desktop/permissions/autogenerated/chatgpt_sign_out.toml new file mode 100644 index 00000000..29e4204a --- /dev/null +++ b/crates/workshop/desktop/permissions/autogenerated/chatgpt_sign_out.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-chatgpt-sign-out" +description = "Enables the chatgpt_sign_out command without any pre-configured scope." +commands.allow = ["chatgpt_sign_out"] + +[[permission]] +identifier = "deny-chatgpt-sign-out" +description = "Denies the chatgpt_sign_out command without any pre-configured scope." +commands.deny = ["chatgpt_sign_out"] diff --git a/crates/workshop/desktop/permissions/autogenerated/chatgpt_status.toml b/crates/workshop/desktop/permissions/autogenerated/chatgpt_status.toml new file mode 100644 index 00000000..463b3deb --- /dev/null +++ b/crates/workshop/desktop/permissions/autogenerated/chatgpt_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-chatgpt-status" +description = "Enables the chatgpt_status command without any pre-configured scope." +commands.allow = ["chatgpt_status"] + +[[permission]] +identifier = "deny-chatgpt-status" +description = "Denies the chatgpt_status command without any pre-configured scope." +commands.deny = ["chatgpt_status"] diff --git a/crates/workshop/desktop/src/chatgpt-tests.rs b/crates/workshop/desktop/src/chatgpt-tests.rs new file mode 100644 index 00000000..e8cc3cf9 --- /dev/null +++ b/crates/workshop/desktop/src/chatgpt-tests.rs @@ -0,0 +1,595 @@ +use workshop_server_api::ChatgptAccount; + +use super::{ + AccountView, CapabilityGate, EnableModelsReply, WAITING_DETAIL, enable_models_for, + enable_outcome, enable_reply_from_body, ipc_json, ipc_json_leaks, reset_view_for, + sign_in_view_for, status_view_for, view_of_status, +}; + +fn status(value: &serde_json::Value) -> AccountView { + view_of_status(value) +} + +#[test] +fn status_views_cover_the_screen_states() { + let waiting = status(&serde_json::json!({ + "status": null, + "sign_in_pending": true, + "available": true, + "email": "a@example.com" + })); + assert_eq!(waiting.state, "waiting"); + assert_eq!(waiting.detail, WAITING_DETAIL); + assert!( + waiting + .detail + .contains("Quitting Workshop stops the Gateway") + ); + + let signed_in = status(&serde_json::json!({ + "status": "active", + "plan_granted": true, + "available": true, + "email": "a@example.com" + })); + assert_eq!(signed_in.state, "signed_in"); + assert!(signed_in.plan_granted); + + let needs = status(&serde_json::json!({ + "status": "needs_auth", + "available": true + })); + assert_eq!(needs.state, "needs_sign_in"); + + let unread = status(&serde_json::json!({ "unreadable": true, "available": true })); + assert_eq!(unread.state, "unreadable"); + + let down = status(&serde_json::json!({ + "available": false, + "unavailable_reason": "Windows" + })); + assert_eq!(down.state, "unavailable"); + assert_eq!(down.detail, "Windows"); +} + +#[test] +fn ipc_payloads_carry_no_secret() { + let view = status(&serde_json::json!({ + "status": "active", + "plan_granted": true, + "available": true, + "email": "a@example.com", + "authorize_url": "http://secret.example/callback", + "owner_proof": "owner-proof-should-not-pass", + "api_key": "bearer-should-not-pass" + })); + let json = ipc_json(&view); + assert!(!ipc_json_leaks(&json), "{json}"); + assert!(!json.contains("secret.example"), "{json}"); + assert!(!json.contains("owner-proof"), "{json}"); + assert!(!json.contains("bearer-should"), "{json}"); + + let reply = enable_reply_from_body( + r#"{"endpoint":"chatgpt-plan","added":["gpt"],"renamed":[{"from":"gpt","to":"gpt-chatgpt"}],"skipped":[{"name":"broken-model","reason":"missing_context_window"}],"authorize_url":"http://nope","access_token":"tok"}"#, + ) + .expect("enable reply"); + let enable_json = ipc_json(&reply); + assert!(!ipc_json_leaks(&enable_json), "{enable_json}"); + assert_eq!( + reply, + EnableModelsReply { + added: vec!["gpt".to_owned()], + renamed: vec![super::RenamedModel { + from: "gpt".to_owned(), + to: "gpt-chatgpt".to_owned(), + }], + skipped: vec![super::SkippedModel { + name: "broken-model".to_owned(), + reason: "missing_context_window".to_owned(), + }], + endpoint: "chatgpt-plan".to_owned(), + } + ); +} + +#[tokio::test] +async fn the_authorize_url_is_passed_to_the_opener_only_and_excluded_from_ipc() { + let (dir, server) = spawn_fake_gateway_sequence(&[ + ( + 200, + r#"{"authorize_url":"https://auth.example/go?state=synth-state"}"#, + ), + ( + 200, + r#"{"status":null,"sign_in_pending":true,"available":true,"authorize_url":"https://auth.example/go?state=synth-state","owner_proof":"synth-owner"}"#, + ), + ]); + let mut opened = None; + let view = sign_in_view_for(&ChatgptAccount::new(dir.path()), |url| { + opened = Some(url.to_owned()); + Ok::<(), &'static str>(()) + }) + .await; + let _ = server.join(); + assert_eq!( + opened.as_deref(), + Some("https://auth.example/go?state=synth-state") + ); + assert_eq!(view.state, "waiting"); + let json = ipc_json(&view); + assert_no_secret_values(&json, &["auth.example", "synth-state", "synth-owner"]); +} + +#[test] +fn a_conflict_envelope_is_not_an_empty_enable() { + let body = r#"{"error":{"message":"secret-draft-path","type":"invalid_request_error","code":"config_has_pending_changes"}}"#; + let parsed = enable_reply_from_body(body).expect("defaulted fields accept the envelope"); + assert!( + parsed.added.is_empty(), + "the old parser treats the envelope as success" + ); + let err = enable_outcome(409, body).expect_err("a 409 is not a successful enable"); + assert_eq!(err, "config_has_pending_changes"); + assert!(!err.contains("secret-draft-path"), "{err}"); + let denied = enable_outcome( + 403, + r#"{"error":{"message":"owner proof","type":"auth","code":"owner_proof_required"}}"#, + ) + .expect_err("a 403 is not a successful enable"); + assert_eq!(denied, "owner_proof_required"); + let ok = enable_outcome( + 200, + r#"{"endpoint":"chatgpt-plan","added":["gpt"],"renamed":[],"skipped":[]}"#, + ) + .expect("a 200 list is the enable reply"); + assert_eq!(ok.added, vec!["gpt".to_owned()]); +} + +#[test] +fn the_capability_binds_once() { + let mut gate = CapabilityGate::default(); + assert!(gate.bind("").is_err()); + gate.bind("capability-token-1").expect("first bind"); + assert!(gate.admit("capability-token-1")); + assert!(!gate.admit("capability-token-2")); + assert!(gate.bind("capability-token-2").is_err()); + assert!(gate.admit("capability-token-1")); +} + +#[test] +fn the_capability_gate_resets_for_main_reload_and_allows_rebind() { + let mut gate = CapabilityGate::default(); + gate.bind("capability-token-1").expect("first bind"); + assert!(gate.admit("capability-token-1")); + + gate.reset_for_main_load(); + assert!(!gate.admit("capability-token-1")); + assert!(!gate.admit("capability-token-2")); + + gate.bind("capability-token-2") + .expect("rebind after main load"); + assert!(gate.admit("capability-token-2")); + assert!(!gate.admit("capability-token-1")); + assert!( + gate.bind("capability-token-3").is_err(), + "refused by CapabilityGate: second bind within reloaded document must fail" + ); +} + +/// `enable_models_for` itself, against a local fake Gateway. A direct call to +/// `enable_outcome` would still pass if this call site went back to treating +/// an error envelope as an empty success. +#[tokio::test] +async fn enable_models_returns_the_gateway_code() { + let conflict = r#"{"error":{"message":"secret-draft-path","type":"invalid_request_error","code":"config_has_pending_changes"}}"#; + let conflicted = enable_against(409, conflict) + .await + .expect_err("a 409 is not a successful enable"); + assert_eq!(conflicted, "config_has_pending_changes"); + assert!(!conflicted.contains("secret-draft-path")); + + let envelope = r#"{"error":{"message":"secret-draft-path","type":"invalid_request_error","code":"owner_proof_required"}}"#; + let denied = enable_against(200, envelope) + .await + .expect_err("a 200 error envelope is not a successful enable"); + assert_eq!(denied, "owner_proof_required"); + assert!(!denied.contains("secret-draft-path")); + + let long = "a".repeat(200); + let body = format!( + r#"{{"error":{{"message":"secret-draft-path","type":"invalid_request_error","code":"{long}"}}}}"# + ); + let bounded = enable_against(409, &body) + .await + .expect_err("a long code is not returned whole"); + assert_eq!(bounded, "gateway_error"); + assert!(!bounded.contains(&long)); +} + +async fn enable_against(status: u16, body: &str) -> Result { + let (dir, server) = spawn_fake_gateway(status, body); + let result = enable_models_for(&ChatgptAccount::new(dir.path())).await; + let _ = server.join(); + result +} + +fn spawn_fake_gateway(status: u16, body: &str) -> (tempfile::TempDir, std::thread::JoinHandle<()>) { + spawn_fake_gateway_sequence(&[(status, body)]) +} + +fn spawn_fake_gateway_sequence( + replies: &[(u16, &str)], +) -> (tempfile::TempDir, std::thread::JoinHandle<()>) { + use std::io::{Read as _, Write as _}; + use workshop_server_api::discovery::{GatewayDiscoveryFile, GatewayPrivateFile}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("the fake gateway binds"); + let port = listener + .local_addr() + .expect("the fake gateway has a port") + .port(); + let owned_replies: Vec<(u16, String)> = replies + .iter() + .map(|(status, body)| (*status, (*body).to_owned())) + .collect(); + let server = std::thread::spawn(move || { + for (status, payload) in owned_replies { + let (mut sock, _) = listener.accept().expect("the gateway request arrives"); + let mut buf = [0_u8; 8192]; + let mut filled = 0; + while filled < buf.len() { + let read = sock.read(&mut buf[filled..]).unwrap_or(0); + if read == 0 { + break; + } + filled += read; + if buf[..filled].windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let header = format!( + "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + let _ = sock.write_all(header.as_bytes()); + let _ = sock.write_all(payload.as_bytes()); + } + }); + let dir = tempfile::tempdir().expect("a temp run dir"); + let discovery = GatewayDiscoveryFile { + port, + api_key: "test-key".to_owned(), + pid: 7, + epoch: 9, + version: "test".to_owned(), + started_at: "1970-01-01T00:00:09Z".to_owned(), + }; + let private = GatewayPrivateFile { + pid: 7, + epoch: 9, + plan_proof: "planproof".to_owned(), + owner_proof: "ownerproof".to_owned(), + }; + std::fs::write( + dir.path().join("gateway.json"), + serde_json::to_vec(&discovery).expect("discovery json"), + ) + .expect("write discovery"); + std::fs::write( + dir.path().join("gateway-private.json"), + serde_json::to_vec(&private).expect("private json"), + ) + .expect("write private"); + (dir, server) +} + +#[test] +fn capability_errors_name_unmet_condition_without_secrets() { + let mut gate = CapabilityGate::default(); + let unbound = gate + .verify("secret-cap-1") + .expect_err("unbound gate must refuse"); + assert!(unbound.contains("none is bound"), "{unbound}"); + assert!(!unbound.contains("secret-cap-1"), "{unbound}"); + + let empty = gate.bind("").expect_err("empty bind must fail"); + assert!(empty.contains("non-empty token"), "{empty}"); + + gate.bind("secret-cap-1").expect("first bind"); + let second = gate + .bind("secret-cap-2") + .expect_err("second bind must fail"); + assert!(second.contains("already holds a capability"), "{second}"); + assert!(!second.contains("secret-cap-1"), "{second}"); + assert!(!second.contains("secret-cap-2"), "{second}"); + + let mismatch = gate + .verify("wrong-cap") + .expect_err("mismatching token must fail"); + assert!(mismatch.contains("did not match"), "{mismatch}"); + assert!(!mismatch.contains("secret-cap-1"), "{mismatch}"); + assert!(!mismatch.contains("wrong-cap"), "{mismatch}"); +} + +#[tokio::test] +async fn gateway_errors_report_cause_without_swallowing_or_leaking() { + let empty_dir = tempfile::tempdir().expect("temp run dir"); + let missing = status_view_for(&ChatgptAccount::new(empty_dir.path())).await; + assert_eq!(missing.state, "unavailable"); + assert!( + missing.detail.contains("discovery file"), + "{}", + missing.detail + ); + assert!(missing.detail.contains("none found"), "{}", missing.detail); + assert!( + !missing.detail.contains("not running"), + "{}", + missing.detail + ); + + let (status_dir, status_server) = spawn_fake_gateway(503, r#"{"error":"busy"}"#); + let status_err = status_view_for(&ChatgptAccount::new(status_dir.path())).await; + let _ = status_server.join(); + assert_eq!(status_err.state, "unavailable"); + assert!( + status_err.detail.contains("HTTP 503"), + "{}", + status_err.detail + ); + assert!( + status_err.detail.contains("required 2xx"), + "{}", + status_err.detail + ); + assert!( + !status_err.detail.contains("test-key"), + "{}", + status_err.detail + ); + assert!( + !status_err.detail.contains("ownerproof"), + "{}", + status_err.detail + ); + + let (post_dir, post_server) = spawn_fake_gateway(500, r#"{"error":"boom"}"#); + let post_err = reset_view_for(&ChatgptAccount::new(post_dir.path())).await; + let _ = post_server.join(); + assert_eq!(post_err.state, "unavailable"); + assert!(post_err.detail.contains("HTTP 500"), "{}", post_err.detail); + assert!(!post_err.detail.contains("test-key"), "{}", post_err.detail); + assert!( + !post_err.detail.contains("ownerproof"), + "{}", + post_err.detail + ); + + let (open_dir, open_server) = spawn_fake_gateway( + 200, + r#"{"authorize_url":"https://auth.example/secret-token"}"#, + ); + let open_err = sign_in_view_for(&ChatgptAccount::new(open_dir.path()), |url| { + Err(format!("launch failed for {url}")) + }) + .await; + let _ = open_server.join(); + assert_eq!(open_err.state, "unavailable"); + assert!( + open_err.detail.contains("system browser failed"), + "{}", + open_err.detail + ); + assert!( + !open_err.detail.contains("launch failed"), + "{}", + open_err.detail + ); + assert!( + !open_err.detail.contains("auth.example"), + "{}", + open_err.detail + ); + assert!( + !open_err.detail.contains("secret-token"), + "{}", + open_err.detail + ); +} + +fn assert_no_secret_values(json: &str, forbidden: &[&str]) { + assert!(!ipc_json_leaks(json), "field-name leak in {json}"); + for secret in forbidden { + assert!( + !json.contains(secret), + "synthetic secret {secret:?} leaked into IPC payload: {json}" + ); + } +} + +#[tokio::test] +async fn wrong_typed_status_and_sidecar_inputs_never_echo_values_across_ipc() { + use workshop_server_api::discovery::{GatewayDiscoveryFile, GatewayPrivateFile}; + + const OWNER_SECRET: &str = "synth-owner-proof-9f8e7d"; + const PLAN_SECRET: &str = "synth-plan-proof-6c5b4a"; + const STATE_SECRET: &str = "synth-oauth-state-3a2b1c"; + const BEARER_SECRET: &str = "synth-api-bearer-4d3c2b"; + let forbidden = [OWNER_SECRET, PLAN_SECRET, STATE_SECRET, BEARER_SECRET]; + + // 1. Wrong-typed status fields: `serde_json`'s data error can echo a string value. + let wrong_status = status(&serde_json::json!({ + "available": OWNER_SECRET, + "plan_granted": PLAN_SECRET, + "sign_in_pending": STATE_SECRET + })); + assert_eq!(wrong_status.state, "unreadable"); + assert_no_secret_values(&ipc_json(&wrong_status), &forbidden); + + let (bad_status_dir, bad_status_server) = spawn_fake_gateway( + 200, + &format!(r#"{{"available":"{OWNER_SECRET}","plan_granted":"{PLAN_SECRET}"}}"#), + ); + let fetched_wrong_status = status_view_for(&ChatgptAccount::new(bad_status_dir.path())).await; + let _ = bad_status_server.join(); + assert_eq!(fetched_wrong_status.state, "unreadable"); + assert_no_secret_values(&ipc_json(&fetched_wrong_status), &forbidden); + + let enable_err = enable_reply_from_body(&format!(r#"{{"added":"{OWNER_SECRET}"}}"#)) + .expect_err("wrong-typed added list must fail"); + assert_no_secret_values(&enable_err, &forbidden); + + // 2. Wrong-typed gateway-private.json and gateway.json sidecars (neither values nor paths leak). + let sidecar_dir = tempfile::tempdir().expect("temp run dir"); + let dir_str = sidecar_dir.path().to_string_lossy().into_owned(); + let discovery = GatewayDiscoveryFile { + port: 43123, + api_key: BEARER_SECRET.to_owned(), + pid: 7, + epoch: 9, + version: "test".to_owned(), + started_at: "1970-01-01T00:00:09Z".to_owned(), + }; + std::fs::write( + sidecar_dir.path().join("gateway.json"), + serde_json::to_vec(&discovery).expect("discovery json"), + ) + .expect("write discovery"); + std::fs::write( + sidecar_dir.path().join("gateway-private.json"), + format!( + r#"{{"pid":"{OWNER_SECRET}","epoch":"{PLAN_SECRET}","plan_proof":"{PLAN_SECRET}","owner_proof":"{OWNER_SECRET}"}}"# + ), + ) + .expect("write wrong-typed private file"); + + let private_parse_view = status_view_for(&ChatgptAccount::new(sidecar_dir.path())).await; + assert_eq!(private_parse_view.state, "unreadable"); + let private_json = ipc_json(&private_parse_view); + assert_no_secret_values(&private_json, &forbidden); + assert!( + !private_json.contains(&dir_str), + "run path leaked: {private_json}" + ); + + // Boot mismatch reports (pid, epoch) rather than an invented boot_id and never echoes proofs. + let mismatched_private = GatewayPrivateFile { + pid: 11, + epoch: 13, + plan_proof: PLAN_SECRET.to_owned(), + owner_proof: OWNER_SECRET.to_owned(), + }; + std::fs::write( + sidecar_dir.path().join("gateway-private.json"), + serde_json::to_vec(&mismatched_private).expect("private json"), + ) + .expect("write mismatched private"); + let mismatch_view = status_view_for(&ChatgptAccount::new(sidecar_dir.path())).await; + assert_eq!(mismatch_view.state, "unreadable"); + assert!( + mismatch_view.detail.contains("(pid, epoch) (7, 9)") + && mismatch_view.detail.contains("(11, 13)"), + "{}", + mismatch_view.detail + ); + assert!( + !mismatch_view.detail.contains("boot_id"), + "{}", + mismatch_view.detail + ); + assert_no_secret_values(&ipc_json(&mismatch_view), &forbidden); + + // Wrong-typed discovery file also keeps values and paths out of IPC. + std::fs::write( + sidecar_dir.path().join("gateway.json"), + format!(r#"{{"port":"{BEARER_SECRET}"}}"#), + ) + .expect("write wrong-typed discovery"); + let discovery_parse_view = status_view_for(&ChatgptAccount::new(sidecar_dir.path())).await; + assert_eq!(discovery_parse_view.state, "unavailable"); + let discovery_json = ipc_json(&discovery_parse_view); + assert_no_secret_values(&discovery_json, &forbidden); + assert!( + !discovery_json.contains(&dir_str), + "run path leaked: {discovery_json}" + ); +} + +#[tokio::test] +async fn malformed_sign_in_replies_and_url_echoing_openers_never_leak_oauth_secrets() { + const STATE_RAW: &str = "synth-oauth-state-3a2b1c"; + const PKCE_RAW: &str = "synth-pkce-challenge-0z9y8x"; + const AUTHORIZE_URL: &str = "https://auth.example/oauth/authorize?state=synth-oauth-state-3a2b1c%2Btail&code_challenge=synth-pkce-challenge-0z9y8x%2Fpad"; + const PERCENT_ENCODED_URL: &str = "https%3A%2F%2Fauth.example%2Foauth%2Fauthorize%3Fstate%3Dsynth-oauth-state-3a2b1c%252Btail%26code_challenge%3Dsynth-pkce-challenge-0z9y8x%252Fpad"; + + let forbidden = [ + "auth.example", + STATE_RAW, + PKCE_RAW, + "%2Btail", + "+tail", + "%252Btail", + "%2Fpad", + "/pad", + "%252Fpad", + "opener-whole-echo", + "opener-partial-echo", + "opener-encoded-echo", + ]; + + // 3a. Truncated/malformed sign-in body carrying raw OAuth state & PKCE challenge. + let truncated_body = format!(r#"{{"authorize_url":"{AUTHORIZE_URL}"#); + let (malformed_dir, malformed_server) = spawn_fake_gateway(200, &truncated_body); + let malformed_view = sign_in_view_for(&ChatgptAccount::new(malformed_dir.path()), |_| { + Ok::<(), &'static str>(()) + }) + .await; + let _ = malformed_server.join(); + assert_eq!(malformed_view.state, "unavailable"); + assert_no_secret_values(&ipc_json(&malformed_view), &forbidden); + + // 3b. Wrong-typed authorize_url field carrying OAuth secrets. + let wrong_type_body = format!(r#"{{"authorize_url":["{STATE_RAW}","{PKCE_RAW}"]}}"#); + let (wrong_dir, wrong_server) = spawn_fake_gateway(200, &wrong_type_body); + let wrong_view = sign_in_view_for(&ChatgptAccount::new(wrong_dir.path()), |_| { + Ok::<(), &'static str>(()) + }) + .await; + let _ = wrong_server.join(); + assert_eq!(wrong_view.state, "unavailable"); + assert_no_secret_values(&ipc_json(&wrong_view), &forbidden); + + // 4. Failing opener echoing the URL whole, in part (decoded query values), and percent-encoded. + let valid_body = format!(r#"{{"authorize_url":"{AUTHORIZE_URL}"}}"#); + let opener_errors = [ + format!("opener-whole-echo: {AUTHORIZE_URL}"), + format!("opener-partial-echo: state={STATE_RAW}+tail challenge={PKCE_RAW}/pad"), + format!("opener-encoded-echo: {PERCENT_ENCODED_URL}"), + ]; + for echoed in opener_errors { + let (dir, server) = spawn_fake_gateway(200, &valid_body); + let view = + sign_in_view_for(&ChatgptAccount::new(dir.path()), |_url| Err(echoed.clone())).await; + let _ = server.join(); + assert_eq!(view.state, "unavailable"); + assert_no_secret_values(&ipc_json(&view), &forbidden); + } +} + +#[test] +fn client_build_error_projects_to_unavailable_with_closed_category() { + use workshop_server_api::{ChatgptAccountError, ChatgptHttpFailure}; + + let view = super::view_of_account_error(ChatgptAccountError::ClientBuild( + ChatgptHttpFailure::Builder, + )); + assert_eq!(view.state, "unavailable"); + assert_eq!( + view.detail, + "Loopback HTTP account client failed to build: client builder error" + ); + assert!(view.email.is_none()); + assert!(!view.plan_granted); + assert!(view.plan_models.is_empty()); + assert_no_secret_values(&ipc_json(&view), &[]); +} diff --git a/crates/workshop/desktop/src/chatgpt.rs b/crates/workshop/desktop/src/chatgpt.rs new file mode 100644 index 00000000..b8bbfa7f --- /dev/null +++ b/crates/workshop/desktop/src/chatgpt.rs @@ -0,0 +1,210 @@ +//! ChatGPT account actions for the desktop process. +//! +//! The web view asks for these over Tauri IPC. [`ChatgptAccount`] in +//! `workshop-gateway` (reached through `workshop-server-api`) reads the sidecar +//! owner proof and calls the Gateway over loopback HTTP, while this module +//! enforces the window capability gate, opens the sign-in URL natively in the +//! system browser, and projects outcomes onto [`AccountView`] and +//! [`EnableModelsReply`]. Values returned to IPC never carry an owner proof, an +//! API bearer, a capability token, or the authorize URL. +//! +//! # Trust boundary: capability gate and owner proof +//! +//! The embedded Gateway config frame runs on its own dedicated loopback +//! origin and is excluded from Workshop's main-origin Tauri remote ACL, while +//! [`capability`] adds per-load capability defense in depth on every account +//! command. Account commands therefore sit behind two separate boundaries: +//! +//! - **Page to desktop ([`capability`]):** The top-level main document +//! binds a fresh capability token once at load through +//! [`chatgpt_bind_capability`]. Every other account command checks that +//! token before touching disk or the Gateway. A second bind on the same +//! load is refused, and [`ChatgptState::reset_for_main_load`] resets the +//! gate only when navigation verifies a top-level main-origin load. +//! - **Desktop to Gateway ([`ChatgptAccount`]):** `workshop-gateway` reads +//! both the `gateway.json` API bearer and the `gateway-private.json` +//! `owner_proof` header for the matching `(pid, epoch)` boot identity. Those +//! secrets remain strictly inside `workshop-gateway` and never enter the +//! desktop view layer or cross IPC. +//! +//! Sign-in launches the external browser natively via `tauri_plugin_opener` +//! inside [`chatgpt_sign_in`] and discards both the authorize URL and any +//! raw OS opener error afterwards so the web view never receives the OAuth +//! URL or URL-bearing error text. + +mod capability; +mod view; + +use workshop_server_api::ChatgptAccount; + +#[cfg(test)] +pub(crate) use capability::CapabilityGate; +pub(crate) use capability::ChatgptState; +use capability::admit; +pub(crate) use view::{AccountView, EnableModelsReply}; +#[cfg(test)] +pub(crate) use view::{RenamedModel, SkippedModel, WAITING_DETAIL, enable_reply_from_body}; +use view::{enable_outcome, refuse_view, view_of_account_error, view_of_status}; +#[cfg(test)] +use view::{ipc_json, ipc_json_leaks}; + +async fn status_view_for(account: &ChatgptAccount) -> AccountView { + match account.status().await { + Ok(body) => view_of_status(&body), + Err(error) => view_of_account_error(error), + } +} + +async fn sign_in_view_for(account: &ChatgptAccount, open: F) -> AccountView +where + F: FnOnce(&str) -> Result<(), E>, +{ + match account.sign_in(open).await { + Ok(body) => view_of_status(&body), + Err(error) => view_of_account_error(error), + } +} + +async fn sign_out_view_for(account: &ChatgptAccount) -> AccountView { + match account.sign_out().await { + Ok(body) => view_of_status(&body), + Err(error) => view_of_account_error(error), + } +} + +async fn reset_view_for(account: &ChatgptAccount) -> AccountView { + match account.reset().await { + Ok(body) => view_of_status(&body), + Err(error) => view_of_account_error(error), + } +} + +async fn enable_models_for(account: &ChatgptAccount) -> Result { + let (status, body) = account + .enable_models() + .await + .map_err(|error| view_of_account_error(error).detail)?; + enable_outcome(status, &body) +} + +/// Binds the per-window-load capability token sent by the top-level main +/// frame. Once bound, subsequent calls fail until the main origin reloads. +/// +/// # Errors +/// Returns an error if `capability` is empty or a capability is already +/// bound for the current main-document load. +#[tauri::command] +#[expect( + clippy::needless_pass_by_value, + reason = "Tauri deserializes command arguments by value" +)] +pub(crate) fn chatgpt_bind_capability( + state: tauri::State<'_, ChatgptState>, + capability: String, +) -> Result<(), String> { + state.bind(&capability) +} + +/// Fetches the sanitized ChatGPT account status from the running Gateway. +/// +/// # Errors +/// Returns an error when the presented `capability` does not match the +/// bound token. Missing run folders or Gateway errors return an +/// unavailable/unreadable [`AccountView`] instead of exposing raw secrets. +#[tauri::command] +pub(crate) async fn chatgpt_status( + state: tauri::State<'_, ChatgptState>, + capability: String, +) -> Result { + admit(&state, &capability)?; + Ok(match ChatgptAccount::from_default_run_dir() { + Some(account) => status_view_for(&account).await, + None => refuse_view(), + }) +} + +/// Starts a ChatGPT OAuth sign-in flow and opens the authorize URL natively +/// in the system browser. The authorize URL never crosses back into the web +/// view, and any OS opener error is replaced with a fixed status sentence. +/// +/// # Errors +/// Returns an error when the presented `capability` does not match the +/// bound token. +#[tauri::command] +pub(crate) async fn chatgpt_sign_in( + app: tauri::AppHandle, + state: tauri::State<'_, ChatgptState>, + capability: String, +) -> Result { + admit(&state, &capability)?; + let Some(account) = ChatgptAccount::from_default_run_dir() else { + return Ok(refuse_view()); + }; + Ok(sign_in_view_for(&account, |url| { + tauri_plugin_opener::OpenerExt::opener(&app).open_url(url, None::<&str>) + }) + .await) +} + +/// Signs out the active ChatGPT session via the Gateway and returns the +/// refreshed account status. +/// +/// # Errors +/// Returns an error when the presented `capability` does not match the +/// bound token. +#[tauri::command] +pub(crate) async fn chatgpt_sign_out( + state: tauri::State<'_, ChatgptState>, + capability: String, +) -> Result { + admit(&state, &capability)?; + Ok(match ChatgptAccount::from_default_run_dir() { + Some(account) => sign_out_view_for(&account).await, + None => refuse_view(), + }) +} + +/// Resets persisted ChatGPT sign-in state via the Gateway and returns the +/// refreshed account status. +/// +/// # Errors +/// Returns an error when the presented `capability` does not match the +/// bound token. +#[tauri::command] +pub(crate) async fn chatgpt_reset( + state: tauri::State<'_, ChatgptState>, + capability: String, +) -> Result { + admit(&state, &capability)?; + Ok(match ChatgptAccount::from_default_run_dir() { + Some(account) => reset_view_for(&account).await, + None => refuse_view(), + }) +} + +/// Requests enabling ChatGPT plan models on the Gateway, returning only the +/// sanitized model outcome lists. +/// +/// # Errors +/// Returns an error if the presented `capability` is invalid, no user run +/// folder resolves, Gateway discovery/transport fails, or the Gateway +/// rejects the request (mapped to bounded error codes such as +/// `config_has_pending_changes`, `owner_proof_required`, or `gateway_error`). +#[tauri::command] +pub(crate) async fn chatgpt_enable( + state: tauri::State<'_, ChatgptState>, + capability: String, +) -> Result { + admit(&state, &capability)?; + let Some(account) = ChatgptAccount::from_default_run_dir() else { + return Err( + "ChatGPT model enable requires a resolved user run folder; none is configured" + .to_owned(), + ); + }; + enable_models_for(&account).await +} + +#[cfg(test)] +#[path = "chatgpt-tests.rs"] +mod tests; diff --git a/crates/workshop/desktop/src/chatgpt/capability.rs b/crates/workshop/desktop/src/chatgpt/capability.rs new file mode 100644 index 00000000..c26b8b49 --- /dev/null +++ b/crates/workshop/desktop/src/chatgpt/capability.rs @@ -0,0 +1,113 @@ +//! Per-window capability gate guarding Tauri account commands. +//! +//! Embedded Gateway config surfaces run on their own dedicated loopback origin +//! excluded from Workshop's main-origin Tauri remote ACL. As defense in depth +//! on top of that origin boundary, [`ChatgptState`] enforces a single-bind +//! capability minted by the main frame at boot and cleared only when the main +//! frame undergoes a verified main-origin top-level navigation. Rejected calls +//! return factual errors naming the unmet state without echoing any stored or +//! presented token. + +/// One boot's capability. The first non-empty token wins. +#[derive(Debug, Default)] +pub(crate) struct CapabilityGate { + bound: Option, +} + +impl CapabilityGate { + /// Binds `token` if none is bound yet. + /// + /// # Errors + /// Returns an error when `token` is empty or a token is already bound. + pub(crate) fn bind(&mut self, token: &str) -> Result<(), &'static str> { + if token.is_empty() { + return Err( + "ChatGPT capability bind requires a non-empty token; actual token is empty", + ); + } + if self.bound.is_some() { + return Err( + "ChatGPT capability bind requires an unbound gate; actual gate already holds a capability", + ); + } + self.bound = Some(token.to_owned()); + Ok(()) + } + + /// Clears the bound token when the main top-level document begins a + /// navigation/reload on the main origin so the reloaded document can + /// bind a fresh capability once. + pub(crate) fn reset_for_main_load(&mut self) { + self.bound = None; + } + + /// Checks `token` against the bound capability without disclosing either value. + /// + /// # Errors + /// Returns an error naming whether no capability is bound or the presented token mismatched. + pub(crate) fn verify(&self, token: &str) -> Result<(), &'static str> { + match self.bound.as_deref() { + None => Err("ChatGPT command requires a bound window capability; none is bound"), + Some(bound) if bound == token => Ok(()), + Some(_) => Err( + "ChatGPT command requires the window-bound capability; presented capability did not match", + ), + } + } + + /// Whether `token` is the bound capability. + #[cfg(test)] + #[must_use] + pub(crate) fn admit(&self, token: &str) -> bool { + self.verify(token).is_ok() + } +} + +/// The bound capability for the process. +#[derive(Default)] +pub(crate) struct ChatgptState { + gate: std::sync::Mutex, +} + +impl ChatgptState { + /// Clears the bound capability when the main window begins loading + /// the trusted main origin (top-level load / reload). + pub(crate) fn reset_for_main_load(&self) { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .reset_for_main_load(); + } + + /// Binds `capability` for the current main-document load. + /// + /// # Errors + /// Returns an error if `capability` is empty or a token is already bound. + pub(super) fn bind(&self, capability: &str) -> Result<(), String> { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bind(capability) + .map_err(str::to_owned) + } + + /// Verifies that `capability` matches the currently bound window token. + /// + /// # Errors + /// Returns an error if no capability is bound or `capability` does not match. + pub(super) fn check(&self, capability: &str) -> Result<(), String> { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .verify(capability) + .map_err(str::to_owned) + } +} + +/// Checks `capability` against `state` before dispatching an account command. +/// +/// # Errors +/// Propagates the capability verification error when unbound or mismatched. +pub(super) fn admit(state: &ChatgptState, capability: &str) -> Result<(), String> { + state.check(capability) +} diff --git a/crates/workshop/desktop/src/chatgpt/view.rs b/crates/workshop/desktop/src/chatgpt/view.rs new file mode 100644 index 00000000..5c9d75d0 --- /dev/null +++ b/crates/workshop/desktop/src/chatgpt/view.rs @@ -0,0 +1,463 @@ +//! IPC view types and Gateway response projections for ChatGPT accounts. +//! +//! Only [`AccountView`] and [`EnableModelsReply`] cross back to the web +//! view. Parsing and projection here drop sensitive fields (the API bearer, +//! owner proof, `authorize_url`, and arbitrary Gateway error prose that may +//! name filesystem paths or pending draft details). + +use serde::{Deserialize, Serialize}; +use workshop_server_api::{ + ChatgptAccountError, ChatgptAccountOp, ChatgptHttpFailure, ChatgptJsonFailure, + ChatgptReadTarget, ChatgptSidecarFailure, +}; + +/// Shown while a sign-in is waiting on the browser. +pub(crate) const WAITING_DETAIL: &str = "Waiting for the browser. Quitting Workshop stops the Gateway, and the browser page will then fail."; + +/// What the web view is allowed to know about the account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AccountView { + /// `signed_out`, `waiting`, `signed_in`, `needs_sign_in`, + /// `unavailable`, or `unreadable`. + pub state: String, + /// The signed-in account's email, when the Gateway has one. + pub email: Option, + /// The account granted plan usage. + pub plan_granted: bool, + /// A short sentence for the status element. + pub detail: String, + /// Caller-facing plan model ids, in catalog order. + #[serde(default)] + pub plan_models: Vec, + /// The Gateway's own status word: `active`, `signed_out`, `needs_auth`. + #[serde(default)] + pub account_status: Option, + /// `confirmed` or `unconfirmed` for the last remote sign-out. + #[serde(default)] + pub revocation: Option, + /// A token the Gateway did not keep may still be live at the issuer. + #[serde(default)] + pub cleanup_unconfirmed: bool, + /// Why sign-in cannot be held, when `state` is `unavailable`. + #[serde(default)] + pub unavailable_reason: Option, +} + +/// One model the Gateway renamed because the original id was taken. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct RenamedModel { + pub from: String, + pub to: String, +} + +/// One model the Gateway did not add, with a closed reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SkippedModel { + pub name: String, + pub reason: String, +} + +/// The reply of `POST /admin/chatgpt/models/enable`. +/// +/// Shapes match the Gateway contract: `added` and `renamed` are the +/// caller-facing ids, and `skipped` is `{name, reason}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct EnableModelsReply { + #[serde(default)] + pub endpoint: String, + #[serde(default)] + pub added: Vec, + #[serde(default)] + pub renamed: Vec, + #[serde(default)] + pub skipped: Vec, +} + +/// The Gateway's status body. Extra fields are ignored. +#[derive(Debug, Deserialize)] +#[expect( + clippy::struct_excessive_bools, + reason = "each flag is a field of the Gateway status body" +)] +struct GatewayStatus { + status: Option, + #[serde(default)] + unreadable: bool, + email: Option, + #[serde(default)] + plan_granted: bool, + #[serde(default)] + sign_in_pending: bool, + #[serde(default = "yes")] + available: bool, + unavailable_reason: Option, + #[serde(default)] + plan_models: Vec, + #[serde(default)] + revocation: Option, + #[serde(default)] + cleanup_unconfirmed: bool, +} + +fn yes() -> bool { + true +} + +fn json_failure_summary(failure: ChatgptJsonFailure) -> String { + let (kind, line, column) = match failure { + ChatgptJsonFailure::Io { line, column } => ("JSON I/O error", line, column), + ChatgptJsonFailure::Syntax { line, column } => ("JSON syntax error", line, column), + ChatgptJsonFailure::Data { line, column } => ("JSON schema mismatch", line, column), + ChatgptJsonFailure::Eof { line, column } => ("unexpected end of JSON", line, column), + }; + if line > 0 { + format!("{kind} at line {line}, column {column}") + } else { + kind.to_owned() + } +} + +fn sidecar_failure_summary(failure: ChatgptSidecarFailure) -> String { + match failure { + ChatgptSidecarFailure::Read => "file I/O read failure".to_owned(), + ChatgptSidecarFailure::Parse(json) => { + format!("malformed JSON ({})", json_failure_summary(json)) + } + ChatgptSidecarFailure::Invalid => "invalid sidecar field".to_owned(), + ChatgptSidecarFailure::Other => "sidecar file error".to_owned(), + } +} + +const fn op_method_and_path(op: ChatgptAccountOp) -> (&'static str, &'static str) { + match op { + ChatgptAccountOp::Status => ("GET", "/admin/chatgpt/status"), + ChatgptAccountOp::SignIn => ("POST", "/admin/chatgpt/sign-in"), + ChatgptAccountOp::SignOut => ("POST", "/admin/chatgpt/sign-out"), + ChatgptAccountOp::Reset => ("POST", "/admin/chatgpt/reset"), + ChatgptAccountOp::EnableModels => ("POST", "/admin/chatgpt/models/enable"), + } +} + +const fn read_target_noun(target: ChatgptReadTarget) -> &'static str { + match target { + ChatgptReadTarget::Status => "status", + ChatgptReadTarget::SignIn => "sign-in", + ChatgptReadTarget::EnableModels => "enable", + } +} + +fn http_failure_kind(failure: ChatgptHttpFailure) -> &'static str { + match failure { + ChatgptHttpFailure::Timeout => "request timed out", + ChatgptHttpFailure::Connect => "connection error", + ChatgptHttpFailure::Decode => "response decode error", + ChatgptHttpFailure::Body => "body transfer error", + ChatgptHttpFailure::Redirect => "redirect blocked", + ChatgptHttpFailure::Builder => "client builder error", + ChatgptHttpFailure::Request => "request build error", + ChatgptHttpFailure::Transport => "transport error", + } +} + +fn op_request_detail(op: ChatgptAccountOp, kind: ChatgptHttpFailure) -> String { + let (method, path) = op_method_and_path(op); + format!( + "Gateway {method} {path} request failed: {}", + http_failure_kind(kind) + ) +} + +fn op_status_detail(op: ChatgptAccountOp, status: u16) -> String { + let (method, path) = op_method_and_path(op); + format!("Gateway {method} {path} returned HTTP {status} (required 2xx success)") +} + +/// Maps a closed [`ChatgptAccountError`] from `workshop-gateway` onto the +/// fixed-sentence [`AccountView`] returned over Tauri IPC. +#[must_use] +pub(super) fn view_of_account_error(error: ChatgptAccountError) -> AccountView { + let (state, detail) = match error { + ChatgptAccountError::DiscoveryMissing => ( + "unavailable", + "Local Gateway unavailable: required discovery file gateway.json, none found" + .to_owned(), + ), + ChatgptAccountError::DiscoveryUnreadable(failure) => ( + "unavailable", + format!( + "Local Gateway discovery file could not be read: {}", + sidecar_failure_summary(failure) + ), + ), + ChatgptAccountError::OwnerProofMissing => ( + "unreadable", + "Gateway owner-proof file is missing: required private sidecar matching the active Gateway, found none" + .to_owned(), + ), + ChatgptAccountError::OwnerProofUnreadable(failure) => ( + "unreadable", + format!( + "Gateway owner-proof file could not be read: {}", + sidecar_failure_summary(failure) + ), + ), + ChatgptAccountError::BootMismatch { + discovery_pid, + discovery_epoch, + private_pid, + private_epoch, + } => ( + "unreadable", + format!( + "Gateway owner-proof file belongs to another Gateway boot: required matching (pid, epoch) ({discovery_pid}, {discovery_epoch}), found ({private_pid}, {private_epoch})" + ), + ), + ChatgptAccountError::ClientBuild(kind) => ( + "unavailable", + format!( + "Loopback HTTP account client failed to build: {}", + http_failure_kind(kind) + ), + ), + ChatgptAccountError::Request { op, kind } => ("unavailable", op_request_detail(op, kind)), + ChatgptAccountError::ReadBody { + target, + status, + kind, + } => ( + "unavailable", + format!( + "Reading Gateway {} reply body failed (HTTP {status}): {}", + read_target_noun(target), + http_failure_kind(kind) + ), + ), + ChatgptAccountError::HttpStatus { op, status } => { + ("unavailable", op_status_detail(op, status)) + } + ChatgptAccountError::StatusInvalidJson(json) => ( + "unreadable", + format!( + "Gateway status reply is not valid JSON: required JSON object, found {}", + json_failure_summary(json) + ), + ), + ChatgptAccountError::SignInInvalidJson(json) => ( + "unavailable", + format!( + "Gateway sign-in reply is not valid JSON: required JSON object with sign-in launch URL, found {}", + json_failure_summary(json) + ), + ), + ChatgptAccountError::SignInMissingUrl => ( + "unavailable", + "Gateway sign-in reply is missing a non-empty sign-in launch URL string".to_owned(), + ), + ChatgptAccountError::BrowserOpen => ( + "unavailable", + "Opening the ChatGPT sign-in page in the system browser failed: required a working system URL handler, found opener error" + .to_owned(), + ), + }; + view(state, None, false, detail, Vec::new()) +} + +/// Maps a Gateway status body onto the view the web view may hold. +#[must_use] +pub(crate) fn view_of_status(status: &serde_json::Value) -> AccountView { + let parsed: GatewayStatus = match serde_json::from_value(status.clone()) { + Ok(parsed) => parsed, + Err(error) => { + return view( + "unreadable", + None, + false, + format!( + "Gateway status payload is unreadable: required valid Gateway status JSON, found {}", + json_failure_summary(ChatgptJsonFailure::from_serde(&error)) + ), + Vec::new(), + ); + } + }; + let plans = parsed.plan_models.clone(); + let mut shown = if !parsed.available { + view( + "unavailable", + None, + false, + parsed.unavailable_reason.clone().unwrap_or_else(|| { + "Gateway reported ChatGPT sign-in unavailable with no reason given".to_owned() + }), + Vec::new(), + ) + } else if parsed.unreadable { + view( + "unreadable", + None, + false, + "Gateway reported saved ChatGPT sign-in record is unreadable (required valid persisted sign-in data)", + Vec::new(), + ) + } else if parsed.sign_in_pending { + view( + "waiting", + parsed.email.clone(), + parsed.plan_granted, + WAITING_DETAIL, + plans, + ) + } else if parsed.status.as_deref() == Some("active") && parsed.plan_granted { + view("signed_in", parsed.email.clone(), true, "Signed in", plans) + } else if parsed.status.as_deref() == Some("needs_auth") + || parsed.status.as_deref() == Some("active") + { + let detail = if parsed.status.as_deref() == Some("active") { + "ChatGPT account is active without plan access; sign in with an account that has plan access".to_owned() + } else { + "ChatGPT session needs sign-in again (required active credentials, current status is needs_auth)".to_owned() + }; + view( + "needs_sign_in", + parsed.email.clone(), + parsed.plan_granted, + detail, + plans, + ) + } else { + view( + "signed_out", + parsed.email.clone(), + false, + "Signed out", + plans, + ) + }; + shown.account_status = parsed.status; + shown.revocation = parsed.revocation; + shown.cleanup_unconfirmed = parsed.cleanup_unconfirmed; + shown.unavailable_reason = parsed.unavailable_reason; + shown +} + +/// Builds an [`AccountView`] with optional status metadata cleared. +fn view( + state: &str, + email: Option, + plan_granted: bool, + detail: impl Into, + plan_models: Vec, +) -> AccountView { + AccountView { + state: state.to_owned(), + email, + plan_granted, + detail: detail.into(), + plan_models, + account_status: None, + revocation: None, + cleanup_unconfirmed: false, + unavailable_reason: None, + } +} + +/// Unavailable status view returned when no writable user run folder resolves. +pub(super) fn refuse_view() -> AccountView { + view( + "unavailable", + None, + false, + "ChatGPT account action requires a resolved user run folder; none is configured", + Vec::new(), + ) +} + +/// The enable reply, keeping only the three lists. +/// +/// # Errors +/// Returns an error when the body is not that JSON object. +#[cfg(test)] +pub(crate) fn enable_reply_from_body(body: &str) -> Result { + serde_json::from_str(body).map_err(|error| { + format!( + "Gateway enable reply is not valid JSON: required EnableModelsReply JSON object, found {}", + json_failure_summary(ChatgptJsonFailure::from_serde(&error)) + ) + }) +} + +/// A successful enable list, or the Gateway's error code. +/// +/// A non-2xx body is not a reply. The code is the only text returned, so a +/// message that names a path or a draft does not reach the web view. +/// `config_has_pending_changes` stays recognizable. +/// +/// # Errors +/// Returns a bounded Gateway error code (`error.code` when short ASCII +/// identifier, otherwise `"gateway_error"`) when `status` is non-2xx, `body` +/// is unreadable, or the JSON body carries an error envelope. +pub(crate) fn enable_outcome(status: u16, body: &str) -> Result { + if !(200..300).contains(&status) { + return Err(enable_error_code(body)); + } + let value: serde_json::Value = + serde_json::from_str(body).map_err(|_| "gateway_error".to_owned())?; + if value.get("added").is_none() || value.get("error").is_some() { + return Err(enable_error_code(body)); + } + serde_json::from_value(value).map_err(|_| "gateway_error".to_owned()) +} + +/// The `error.code` string, when it is a short token. Otherwise a fixed word. +const ENABLE_CODE_LIMIT: usize = 64; + +fn enable_error_code(body: &str) -> String { + let code = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error") + .and_then(|error| error.get("code")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }); + match code { + Some(code) + if !code.is_empty() + && code.chars().count() <= ENABLE_CODE_LIMIT + && code.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') => + { + code + } + _ => "gateway_error".to_owned(), + } +} + +/// JSON for an IPC payload. The caller checks it carries no secret. +#[must_use] +#[cfg(test)] +pub(super) fn ipc_json(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_owned()) +} + +/// Whether `json` names a secret the web view must not receive. +#[must_use] +#[cfg(test)] +pub(super) fn ipc_json_leaks(json: &str) -> bool { + const NEEDLES: &[&str] = &[ + "authorize_url", + "authorizeUrl", + "owner_proof", + "ownerProof", + "plan_proof", + "planProof", + "api_key", + "apiKey", + "access_token", + "accessToken", + "refresh_token", + "refreshToken", + ]; + NEEDLES.iter().any(|needle| json.contains(needle)) +} diff --git a/crates/workshop/desktop/src/main.rs b/crates/workshop/desktop/src/main.rs index 802b354e..cba0b742 100644 --- a/crates/workshop/desktop/src/main.rs +++ b/crates/workshop/desktop/src/main.rs @@ -31,6 +31,7 @@ #[cfg(target_os = "windows")] #[expect(unsafe_code, reason = "raw WebView2 COM has no safe wrapper")] mod bridge; +mod chatgpt; mod config; mod drops; mod gateway; @@ -48,6 +49,7 @@ use std::time::Duration; use anyhow::Context as _; use tauri::ipc::CapabilityBuilder; +use tauri::webview::PageLoadEvent; use tauri::{Manager as _, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_dialog::{DialogExt as _, MessageDialogKind}; use tauri_plugin_opener::OpenerExt as _; @@ -83,6 +85,12 @@ const WINDOW_PERMISSIONS: &[&str] = &[ "process:allow-restart", "allow-desktop-update-supported", "allow-quit", + "allow-chatgpt-bind-capability", + "allow-chatgpt-status", + "allow-chatgpt-sign-in", + "allow-chatgpt-sign-out", + "allow-chatgpt-reset", + "allow-chatgpt-enable", ]; fn main() -> ExitCode { @@ -177,7 +185,13 @@ fn run() -> anyhow::Result<()> { .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ desktop_update_supported, - quit::quit + quit::quit, + chatgpt::chatgpt_bind_capability, + chatgpt::chatgpt_status, + chatgpt::chatgpt_sign_in, + chatgpt::chatgpt_sign_out, + chatgpt::chatgpt_reset, + chatgpt::chatgpt_enable ]) .on_menu_event(menu::handle_event) .setup(boot_and_open); @@ -257,15 +271,16 @@ fn continue_teardown( /// while the dialog is up, because some platforms run dialogs on the event /// loop. fn boot_and_open(app: &mut tauri::App) -> Result<(), Box> { + app.manage(chatgpt::ChatgptState::default()); match boot() { - Ok((server, url, attachment, supervisor)) => { + Ok((server, url, config_url, attachment, supervisor)) => { // The capability must exist before the window does: the // authority resolves a window's grants at creation. app.add_capability(window_capability(&url))?; app.manage(GatewaySupervisorSlot::new(supervisor)); app.manage(ServerSlot::new(Some(server))); menu::install(app, attachment.sidecar_identity().is_some())?; - open_window(app, &url) + open_window(app, &url, &config_url) } Err(error) => { eprintln!("{error:?}"); @@ -294,6 +309,7 @@ fn boot_and_open(app: &mut tauri::App) -> Result<(), Box> fn boot() -> anyhow::Result<( ServerHandle, url::Url, + url::Url, gateway::GatewayAttachment, Option, )> { @@ -307,6 +323,17 @@ fn boot() -> anyhow::Result<( { Ok(()) => { let url = url::Url::parse(server.url()).context("parse the workshop URL")?; + let config_url = match url::Url::parse(server.config_origin()) + .context("parse the workshop config URL") + { + Ok(config_url) => config_url, + Err(error) => { + if let Err(shutdown_error) = server.shutdown() { + eprintln!("{shutdown_error:?}"); + } + return Err(error); + } + }; let supervisor = match gateway::supervise(&attachment, server.gateway_updater()) { Ok(supervisor) => supervisor, Err(error) => { @@ -316,7 +343,7 @@ fn boot() -> anyhow::Result<( return Err(error.context("supervise the local gateway")); } }; - Ok((server, url, attachment, supervisor)) + Ok((server, url, config_url, attachment, supervisor)) } Err(error) => { if let Err(shutdown_error) = server.shutdown() { @@ -352,15 +379,27 @@ fn window_capability(url: &url::Url) -> CapabilityBuilder { /// on Linux, restored to the open workspace file's saved /// geometry, then shown. The saver that writes geometry back into the /// file is installed before the show, so the first layout pass is saved. -fn open_window(app: &mut tauri::App, url: &url::Url) -> Result<(), Box> { +fn open_window( + app: &mut tauri::App, + url: &url::Url, + config_url: &url::Url, +) -> Result<(), Box> { let server_origin = url.origin(); + let nav_origin = server_origin.clone(); + let main_url = url.clone(); + let config_origin = config_url.origin(); let opener = app.handle().clone(); + let load_opener = opener.clone(); let builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url.clone())) .title("Runvil") .inner_size(1024.0, 768.0) .visible(false) .on_navigation(move |target| { - match navigation::classify_navigation(&server_origin, target.as_str()) { + match navigation::classify_navigation_with_config( + &nav_origin, + Some(&config_origin), + target.as_str(), + ) { navigation::Navigation::Allow => true, navigation::Navigation::OpenExternally => { if let Err(error) = opener.opener().open_url(target.as_str(), None::<&str>) { @@ -369,6 +408,33 @@ fn open_window(app: &mut tauri::App, url: &url::Url) -> Result<(), Box { + window + .state::() + .reset_for_main_load(); + } + navigation::TopLevelLoadAction::IgnoreNonHttp => {} + navigation::TopLevelLoadAction::RejectAndReturnMain => { + if let Err(error) = load_opener + .opener() + .open_url(payload.url().as_str(), None::<&str>) + { + eprintln!( + "could not open rejected top-level target {} in the system browser: {error}", + payload.url() + ); + } + if let Err(error) = window.navigate(main_url.clone()) { + eprintln!("could not return top frame to main origin: {error}"); + } + } + } }); // Tauri's drag-drop handler suppresses HTML5 drag events on Windows // (https://github.com/tauri-apps/tauri/issues/15138), so Windows keeps @@ -499,4 +565,41 @@ mod tests { ); } } + + #[test] + fn non_main_origin_bind_between_reset_and_main_bind_is_refused_by_tauri_acl() { + // Build the production Tauri capability attached to the `main` window. + let main_url = url::Url::parse("http://127.0.0.1:49152/").expect("main URL"); + let config_url = url::Url::parse("http://127.0.0.1:49153/?mode=panel").expect("config URL"); + let CapabilityFile::Capability(capability) = window_capability(&main_url).build() else { + panic!("the builder produces a single capability"); + }; + let remote = capability.remote.expect("remote origins must be scoped"); + + // Layer that refuses: Tauri's IPC runtime ACL (`Capability::remote` / `Origin::Remote`). + // Even during the window between `ChatgptState::reset_for_main_load()` and the + // reloaded main document's `chatgpt_bind_capability` call, no frame on `config_url` + // (or any origin other than `main_url`) matches the Tauri ACL grant. + assert!( + !capability.local, + "refused by Tauri ACL: local origin disabled" + ); + assert!( + WINDOW_PERMISSIONS.contains(&"allow-chatgpt-bind-capability"), + "chatgpt_bind_capability is governed by the window ACL capability" + ); + let config_origin = config_url.origin().ascii_serialization(); + assert_eq!( + remote.urls, + vec![main_url.origin().ascii_serialization()], + "refused by Tauri ACL: grant admits only main origin, never {config_origin}" + ); + assert!( + !remote + .urls + .iter() + .any(|allowed| allowed == &config_origin || allowed.contains('*')), + "refused by Tauri ACL: config origin cannot invoke chatgpt_bind_capability before main bind" + ); + } } diff --git a/crates/workshop/desktop/src/navigation.rs b/crates/workshop/desktop/src/navigation.rs index 72359eae..f5c6b164 100644 --- a/crates/workshop/desktop/src/navigation.rs +++ b/crates/workshop/desktop/src/navigation.rs @@ -16,11 +16,31 @@ pub(crate) enum Navigation { OpenExternally, } -/// Classifies a navigation target against the server's origin: a -/// same-origin http(s) URL (the in-process server) loads in the webview, -/// while any other absolute http(s) URL opens in the system browser. +/// What the webview does when a top-level page load begins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TopLevelLoadAction { + /// The top frame is loading the trusted main origin: reset capability state + /// so the newly loaded main document can perform its single bind. + AllowMainAndResetCapability, + /// Non-HTTP scheme (`about:blank`, etc.): ignore without resetting or redirecting. + IgnoreNonHttp, + /// Top-level load targets a non-main HTTP(S) origin (such as the config + /// origin): refuse staying there and bounce back to the main origin. + RejectAndReturnMain, +} + +/// Classifies a navigation target against both the main server origin +/// and the dedicated Gateway config-panel origin. +/// +/// Only the main origin may load arbitrarily; the dedicated config origin +/// is admitted strictly at its docked panel entry URL (`/?mode=panel&bridge=
`), +/// so bare or user-driven navigations to the config origin open externally. #[must_use] -pub(crate) fn classify_navigation(server: &url::Origin, target: &str) -> Navigation { +pub(crate) fn classify_navigation_with_config( + server: &url::Origin, + config_origin: Option<&url::Origin>, + target: &str, +) -> Navigation { let Ok(url) = url::Url::parse(target) else { return Navigation::Allow; }; @@ -28,15 +48,52 @@ pub(crate) fn classify_navigation(server: &url::Origin, target: &str) -> Navigat return Navigation::Allow; } if url.origin() == *server { - Navigation::Allow + return Navigation::Allow; + } + if config_origin.is_some_and(|allowed| is_panel_entry_url(server, allowed, &url)) { + return Navigation::Allow; + } + Navigation::OpenExternally +} + +fn is_panel_entry_url(server: &url::Origin, config_origin: &url::Origin, url: &url::Url) -> bool { + if url.origin() != *config_origin || url.path() != "/" { + return false; + } + let expected_bridge = server.ascii_serialization(); + let mut saw_panel_mode = false; + let mut saw_bridge = false; + for (key, value) in url.query_pairs() { + match key.as_ref() { + "mode" if value == "panel" => saw_panel_mode = true, + "bridge" if value == expected_bridge => saw_bridge = true, + _ => return false, + } + } + saw_panel_mode && saw_bridge +} + +/// Classifies a top-level `PageLoadEvent::Started` URL observed in Rust. +#[must_use] +pub(crate) fn classify_top_level_load( + server: &url::Origin, + loaded_url: &url::Url, +) -> TopLevelLoadAction { + if !matches!(loaded_url.scheme(), "http" | "https") { + return TopLevelLoadAction::IgnoreNonHttp; + } + if loaded_url.origin() == *server { + TopLevelLoadAction::AllowMainAndResetCapability } else { - Navigation::OpenExternally + TopLevelLoadAction::RejectAndReturnMain } } #[cfg(test)] mod tests { - use super::{Navigation, classify_navigation}; + use super::{ + Navigation, TopLevelLoadAction, classify_navigation_with_config, classify_top_level_load, + }; /// The origin of the in-process server the test window was opened on. fn server_origin(url: &str) -> url::Origin { @@ -55,7 +112,7 @@ mod tests { "http://127.0.0.1:7910/settings?tab=keys#top", ] { assert_eq!( - classify_navigation(&server, url), + classify_navigation_with_config(&server, None, url), Navigation::Allow, "{url}" ); @@ -66,7 +123,7 @@ mod tests { fn a_default_port_normalizes_into_the_origin() { let server = server_origin("https://127.0.0.1/"); assert_eq!( - classify_navigation(&server, "https://127.0.0.1:443/"), + classify_navigation_with_config(&server, None, "https://127.0.0.1:443/"), Navigation::Allow ); } @@ -83,7 +140,7 @@ mod tests { "http://[::1]:7910/", ] { assert_eq!( - classify_navigation(&server, url), + classify_navigation_with_config(&server, None, url), Navigation::OpenExternally, "{url}" ); @@ -99,13 +156,63 @@ mod tests { "https://localhost.evil.example/", ] { assert_eq!( - classify_navigation(&server, url), + classify_navigation_with_config(&server, None, url), Navigation::OpenExternally, "{url}" ); } } + #[test] + fn configured_iframe_origin_loads_while_top_level_and_other_ports_open_externally() { + let server = server_origin("http://127.0.0.1:7910/"); + let config = server_origin("http://127.0.0.1:7911/"); + assert_eq!( + classify_navigation_with_config( + &server, + Some(&config), + "http://127.0.0.1:7911/?mode=panel&bridge=http%3A%2F%2F127.0.0.1%3A7910" + ), + Navigation::Allow + ); + for refused in [ + "http://127.0.0.1:7911/", + "http://127.0.0.1:7911/other", + "http://127.0.0.1:7911/?mode=panel&bridge=https%3A%2F%2Fevil.example", + "http://127.0.0.1:7912/", + ] { + assert_eq!( + classify_navigation_with_config(&server, Some(&config), refused), + Navigation::OpenExternally, + "{refused}" + ); + } + } + + #[test] + fn top_level_page_load_resets_only_for_main_origin_and_rejects_config_origin() { + let server = server_origin("http://127.0.0.1:7910/"); + let main_url = url::Url::parse("http://127.0.0.1:7910/").expect("main URL"); + let config_url = url::Url::parse( + "http://127.0.0.1:7911/?mode=panel&bridge=http%3A%2F%2F127.0.0.1%3A7910", + ) + .expect("config URL"); + let blank_url = url::Url::parse("about:blank").expect("blank URL"); + + assert_eq!( + classify_top_level_load(&server, &main_url), + TopLevelLoadAction::AllowMainAndResetCapability + ); + assert_eq!( + classify_top_level_load(&server, &config_url), + TopLevelLoadAction::RejectAndReturnMain + ); + assert_eq!( + classify_top_level_load(&server, &blank_url), + TopLevelLoadAction::IgnoreNonHttp + ); + } + #[test] fn non_http_and_unparseable_targets_are_left_to_the_webview() { let server = server_origin("http://127.0.0.1:7910/"); @@ -116,7 +223,7 @@ mod tests { "/relative/path", ] { assert_eq!( - classify_navigation(&server, url), + classify_navigation_with_config(&server, None, url), Navigation::Allow, "{url}" ); diff --git a/crates/workshop/gateway-discovery/CONTRACT.md b/crates/workshop/gateway-discovery/CONTRACT.md index f53d5e86..033a479c 100644 --- a/crates/workshop/gateway-discovery/CONTRACT.md +++ b/crates/workshop/gateway-discovery/CONTRACT.md @@ -1,10 +1,11 @@ # The Gateway discovery contract -The Gateway and Workshop meet through a file plus three HTTP calls. Nothing else is shared. This document is the whole of that meeting point: the file format, the two lock files, the stale rule, the shutdown route, the three calls, and the fixtures that pin them. +The Gateway and Workshop meet through a file plus three HTTP calls. Nothing else decides attachment. This document is the whole of that meeting point: the file format, the two lock files, the stale rule, the shutdown route, the three calls, and the fixtures that pin them. Workshop also reads `gateway-private.json` in the same directory, only to pair a plan proof with the boot the discovery file names. That read is not an attach decision. Both sides are written against this text. Workshop's reader is [`workshop-gateway-discovery`](src/lib.rs), and the Gateway's writer lives in the `runvil-gateway` repository, which spells the same protocol from the other side. When the two disagree, this document is right and one of them has a bug. -- Names in the file: `gateway.json`, `gateway.json.lock`, `gateway.instance.lock`, `gateway.log` +- Names in the run directory: `gateway.json`, `gateway-private.json`, `gateway.json.lock`, `gateway.instance.lock` +- Log: `gateway.log` - Endpoints: `GET /health`, `GET /v1/models`, `POST /shutdown` - Address: `127.0.0.1:{port}`, never `localhost` - Contract fixtures: [`fixtures/`](fixtures/) @@ -68,13 +69,35 @@ The schema below is the machine-readable form of the field table. It is written `additionalProperties` is `true` on purpose: unknown fields are tolerated. The `api_key` pattern is the "no line break" rule. `epoch` allows `0` even though a zero epoch makes the file stale, because the field is present-and-informational while the boot identity rule decides staleness. +## The private file + +`gateway-private.json` sits in the run directory beside `gateway.json`. The Gateway writes it. Workshop reads it and does not write it in a production build. A missing, unreadable, invalid, or unpaired file supplies no plan proof and does not change attachment, staleness, or any of the three HTTP calls. + +The two files carry the same `pid` and `epoch`. A reader uses the plan proof only when those agree with the boot it is attaching to. A first read that disagrees is read again, together with the discovery file, and the proof is used only when that second pair agrees with each other and with the boot already in hand. A proof from a different boot is not sent. + +```json +{ + "pid": 4242, + "epoch": 1757000000, + "plan_proof": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "owner_proof": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +} +``` + +- `pid` (`u32`): the Gateway process id. Must not be `0`, and must equal the discovery file's `pid` before a proof is used. +- `epoch` (`u64`): the boot instant in unix seconds. Must equal the discovery file's `epoch` before a proof is used. +- `plan_proof` (string): presented on a plan-model call, in a header, on top of ordinary authentication. Not a bearer. Non-empty printable ASCII with no spaces. +- `owner_proof` (string): the same character rule, and different from `plan_proof`. Workshop's model calls do not send it. + +Unknown fields are tolerated. Neither proof is written to a log or to `Debug` output. The proofs are 64 lowercase hex characters as the Gateway mints them; a reader accepts any proof that passes the character rule. + ## The run directory and the two locks ### Where things live - The run directory is `/.runvil/run`, where `` is the user profile directory the OS reports. - The state directory is `/.runvil`, the run directory's parent. -- `gateway.json`, `gateway.json.lock`, and `gateway.instance.lock` all sit in the run directory, beside each other. +- `gateway.json`, `gateway-private.json`, `gateway.json.lock`, and `gateway.instance.lock` all sit in the run directory, beside each other. - `gateway.log` sits in `/.runvil/logs/gateway.log`, beside the run directory rather than in it, because the Gateway starts logging before it loads its configuration, so a Gateway that exits on its configuration still records why. ### The atomic owner-only write diff --git a/crates/workshop/gateway-discovery/src/lib.rs b/crates/workshop/gateway-discovery/src/lib.rs index 762ba3b9..b2d2cd86 100644 --- a/crates/workshop/gateway-discovery/src/lib.rs +++ b/crates/workshop/gateway-discovery/src/lib.rs @@ -51,10 +51,13 @@ //! one here. `cargo test -p build-xtask` enforces the product and //! container boundaries. //! - The discovery file, the two lock file names, the stale rule, and the -//! three HTTP calls are exactly what `CONTRACT.md` says. A behavior change -//! here is a contract change, and `CONTRACT.md` changes with it. -//! - A bearer key is never written to logs or `Debug` output. Every error -//! path that could echo a rejected request's status line redacts it. +//! three HTTP calls are exactly what `CONTRACT.md` says. The private file +//! beside them is read only to pair a plan proof, and it is not an attach +//! decision. A behavior change here is a contract change, and +//! `CONTRACT.md` changes with it. +//! - A bearer key is never written to logs or `Debug` output. A plan proof +//! and an owner proof are redacted the same way. Every error path that +//! could echo a rejected request's status line redacts it. //! - Reading the file tolerates fields this crate does not know, so a newer //! Gateway never breaks an older Workshop. //! - No writer ships in a production build: the file is the Gateway's to @@ -69,6 +72,7 @@ mod health; mod lock; mod paths; mod peer; +mod private; mod shutdown; mod stale; mod sys; @@ -84,15 +88,16 @@ pub use crate::health::{HealthError, ProbeError, wait_for_health, wait_for_healt pub use crate::lock::GatewayInstanceLease; pub use crate::lock::{LaunchDecision, LaunchLock, launch_or_attach, launch_or_attach_cancellable}; pub use crate::paths::{ - GATEWAY_DISCOVERY_FILE_NAME, GATEWAY_LOG_FILE_NAME, INSTANCE_LOCK_FILE_NAME, LOCK_FILE_NAME, - default_run_dir, gateway_discovery_file_path, gateway_log_path, instance_lock_file_path, - lock_file_path, run_dir, + GATEWAY_DISCOVERY_FILE_NAME, GATEWAY_LOG_FILE_NAME, GATEWAY_PRIVATE_FILE_NAME, + INSTANCE_LOCK_FILE_NAME, LOCK_FILE_NAME, default_run_dir, gateway_discovery_file_path, + gateway_log_path, gateway_private_file_path, instance_lock_file_path, lock_file_path, run_dir, }; pub use crate::peer::{ APPIMAGE_PLUGIN_PARENT, GATEWAY_BUNDLE_NAME, MACOS_PLUGIN_PARENT, PLUGIN_DIR_NAME, WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, installed_gateway, installed_plugin_dir, running_appimage, }; +pub use crate::private::{GatewayPrivateFile, plan_proof_for_boot}; pub use crate::shutdown::{ShutdownError, request_shutdown, request_shutdown_before}; #[cfg(any(test, feature = "test-fixtures"))] #[doc(hidden)] diff --git a/crates/workshop/gateway-discovery/src/paths.rs b/crates/workshop/gateway-discovery/src/paths.rs index f5bee0bf..eb73806e 100644 --- a/crates/workshop/gateway-discovery/src/paths.rs +++ b/crates/workshop/gateway-discovery/src/paths.rs @@ -7,6 +7,9 @@ use std::path::{Path, PathBuf}; /// The gateway discovery file's name inside the run directory. pub const GATEWAY_DISCOVERY_FILE_NAME: &str = "gateway.json"; +/// The private proofs file's name, beside the discovery file. +pub const GATEWAY_PRIVATE_FILE_NAME: &str = "gateway-private.json"; + /// The gateway log's name inside the state directory's `logs` directory. pub const GATEWAY_LOG_FILE_NAME: &str = "gateway.log"; @@ -37,6 +40,12 @@ pub fn gateway_discovery_file_path(run_dir: &Path) -> PathBuf { run_dir.join(GATEWAY_DISCOVERY_FILE_NAME) } +/// The private proofs file inside `run_dir`. +#[must_use] +pub fn gateway_private_file_path(run_dir: &Path) -> PathBuf { + run_dir.join(GATEWAY_PRIVATE_FILE_NAME) +} + /// The launch lock inside `run_dir`. #[must_use] pub fn lock_file_path(run_dir: &Path) -> PathBuf { @@ -78,6 +87,10 @@ mod tests { gateway_discovery_file_path(dir), Path::new("run").join("gateway.json") ); + assert_eq!( + gateway_private_file_path(dir), + Path::new("run").join("gateway-private.json") + ); assert_eq!( lock_file_path(dir), Path::new("run").join("gateway.json.lock") diff --git a/crates/workshop/gateway-discovery/src/private-tests.rs b/crates/workshop/gateway-discovery/src/private-tests.rs new file mode 100644 index 00000000..d16f9a53 --- /dev/null +++ b/crates/workshop/gateway-discovery/src/private-tests.rs @@ -0,0 +1,149 @@ +//! The private file pairs a plan proof to one boot, redacts both proofs, +//! and yields no proof when the pair does not hold. + +use std::fs; + +use super::{GatewayPrivateFile, paired_proof, plan_proof_for_boot}; +use crate::file::GatewayDiscoveryFile; +use crate::paths::gateway_private_file_path; + +const PLAN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OWNER: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +fn file() -> GatewayPrivateFile { + GatewayPrivateFile { + pid: 4242, + epoch: 1_757_000_000, + plan_proof: PLAN.to_owned(), + owner_proof: OWNER.to_owned(), + } +} + +fn discovery(pid: u32, epoch: u64) -> GatewayDiscoveryFile { + GatewayDiscoveryFile { + port: 8081, + api_key: "key".to_owned(), + pid, + epoch, + version: "0.3.0".to_owned(), + started_at: "2026-11-13T12:00:00Z".to_owned(), + } +} + +fn write(dir: &std::path::Path, value: &impl serde::Serialize) { + fs::write( + gateway_private_file_path(dir), + serde_json::to_vec(value).expect("the fixture serializes"), + ) + .expect("the fixture writes"); +} + +#[test] +fn a_matching_file_returns_the_plan_proof() { + let dir = tempfile::TempDir::new().expect("tempdir"); + write(dir.path(), &file()); + assert_eq!( + plan_proof_for_boot(dir.path(), 4242, 1_757_000_000).as_deref(), + Some(PLAN) + ); +} + +#[test] +fn a_missing_file_returns_none() { + let dir = tempfile::TempDir::new().expect("tempdir"); + assert_eq!(plan_proof_for_boot(dir.path(), 4242, 1_757_000_000), None); + assert_eq!( + GatewayPrivateFile::read(dir.path()).expect("a missing file is not an error"), + None + ); +} + +#[test] +fn a_private_file_for_another_boot_returns_none() { + let dir = tempfile::TempDir::new().expect("tempdir"); + write(dir.path(), &file()); + fs::write( + dir.path().join("gateway.json"), + serde_json::to_vec(&discovery(9999, 2)).expect("discovery serializes"), + ) + .expect("discovery writes"); + assert_eq!( + plan_proof_for_boot(dir.path(), 9999, 2), + None, + "a proof from another boot is not used" + ); + assert_eq!( + plan_proof_for_boot(dir.path(), 4242, 1).as_deref(), + None, + "a different epoch is another boot" + ); +} + +#[test] +fn a_second_read_uses_the_proof_only_when_both_files_name_the_requested_boot() { + let private = file(); + let same = discovery(4242, 1_757_000_000); + assert_eq!( + paired_proof(&private, &same, 4242, 1_757_000_000).as_deref(), + Some(PLAN) + ); + let other = discovery(9999, 2); + assert_eq!(paired_proof(&private, &other, 4242, 1_757_000_000), None); + assert_eq!(paired_proof(&private, &same, 9999, 2), None); +} + +#[test] +fn an_invalid_file_is_an_error_whose_text_omits_both_proofs() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let mut invalid = file(); + invalid.pid = 0; + write(dir.path(), &invalid); + let error = GatewayPrivateFile::read(dir.path()).expect_err("pid 0 is invalid"); + let rendered = error.to_string(); + assert!(rendered.contains("pid must not be 0"), "{rendered}"); + assert!(!rendered.contains(PLAN), "{rendered}"); + assert!(!rendered.contains(OWNER), "{rendered}"); + assert_eq!(plan_proof_for_boot(dir.path(), 0, 1_757_000_000), None); +} + +#[test] +fn validation_refuses_an_empty_proof_a_space_and_two_equal_proofs() { + let mut empty = file(); + empty.plan_proof.clear(); + assert_eq!(empty.validation_error(), Some("a proof must not be empty")); + let mut spaced = file(); + spaced.plan_proof = "not a header".to_owned(); + assert_eq!( + spaced.validation_error(), + Some("a proof must be printable ASCII with no spaces") + ); + let mut same = file(); + same.owner_proof = same.plan_proof.clone(); + assert_eq!(same.validation_error(), Some("the two proofs must differ")); +} + +#[test] +fn an_unknown_field_is_tolerated() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let raw = serde_json::json!({ + "pid": 4242, + "epoch": 1_757_000_000, + "plan_proof": PLAN, + "owner_proof": OWNER, + "extra": "kept-by-a-newer-gateway", + }); + write(dir.path(), &raw); + let read = GatewayPrivateFile::read(dir.path()) + .expect("unknown fields are not an error") + .expect("the file exists"); + assert_eq!(read, file()); +} + +#[test] +fn debug_redacts_both_proofs() { + let rendered = format!("{:?}", file()); + assert!(!rendered.contains(PLAN), "{rendered}"); + assert!(!rendered.contains(OWNER), "{rendered}"); + assert!(rendered.contains("[REDACTED]"), "{rendered}"); + assert!(rendered.contains("4242"), "{rendered}"); +} diff --git a/crates/workshop/gateway-discovery/src/private.rs b/crates/workshop/gateway-discovery/src/private.rs new file mode 100644 index 00000000..6fc8aa4a --- /dev/null +++ b/crates/workshop/gateway-discovery/src/private.rs @@ -0,0 +1,151 @@ +//! The `gateway-private.json` file beside the discovery file. +//! +//! The Gateway mints it at each start. Workshop reads it only to pair a +//! plan proof with the boot the discovery file names. The file is not an +//! attach decision: a missing, unreadable, invalid, or unpaired file +//! leaves attachment unchanged and supplies no proof. This crate has no +//! production writer. The Gateway owns the file. + +use std::fmt; +use std::fs; +use std::io; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::SidecarError; +use crate::file::GatewayDiscoveryFile; +use crate::paths::gateway_private_file_path; + +/// The on-disk proofs of one gateway boot. +/// +/// Readers must tolerate unknown fields, the same way they tolerate them +/// on the discovery file. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GatewayPrivateFile { + /// The gateway process id, equal to the discovery file's. + pub pid: u32, + /// The gateway's boot instant in unix seconds, equal to the discovery + /// file's. + pub epoch: u64, + /// Presented on a plan-model call. Not a bearer. + pub plan_proof: String, + /// Presented on a custody-affecting admin call. Not a bearer, and not + /// sent by this crate. + pub owner_proof: String, +} + +impl fmt::Debug for GatewayPrivateFile { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayPrivateFile") + .field("pid", &self.pid) + .field("epoch", &self.epoch) + .field("plan_proof", &"[REDACTED]") + .field("owner_proof", &"[REDACTED]") + .finish() + } +} + +impl GatewayPrivateFile { + /// Whether these proofs belong to the boot `discovery` names. + #[must_use] + pub fn names_boot_of(&self, discovery: &GatewayDiscoveryFile) -> bool { + self.pid == discovery.pid && self.epoch == discovery.epoch + } + + /// The reason the file fails validation, or `None` when it is valid. + #[must_use] + pub fn validation_error(&self) -> Option<&'static str> { + if self.pid == 0 { + return Some("pid must not be 0"); + } + for proof in [&self.plan_proof, &self.owner_proof] { + if proof.is_empty() { + return Some("a proof must not be empty"); + } + if !proof.bytes().all(|byte| byte.is_ascii_graphic()) { + return Some("a proof must be printable ASCII with no spaces"); + } + } + if self.plan_proof == self.owner_proof { + return Some("the two proofs must differ"); + } + None + } + + /// Reads and validates the private file in `run_dir`, returning `None` + /// when no file exists. + /// + /// # Errors + /// Returns [`SidecarError::Read`] when the file exists but cannot be + /// read, [`SidecarError::Parse`] when it is not valid JSON, and + /// [`SidecarError::Invalid`] when it fails validation. + pub fn read(run_dir: &Path) -> Result, SidecarError> { + let path = gateway_private_file_path(run_dir); + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(SidecarError::Read { + path, + source: error, + }); + } + }; + let file: GatewayPrivateFile = + serde_json::from_str(&raw).map_err(|source| SidecarError::Parse { + path: path.clone(), + source, + })?; + if let Some(reason) = file.validation_error() { + return Err(SidecarError::Invalid { + path, + reason: reason.to_owned(), + }); + } + Ok(Some(file)) + } +} + +/// The plan proof that belongs to the boot `pid` and `epoch` name. +/// +/// A missing, unreadable, or invalid file yields `None`. A file whose pid +/// or epoch disagrees is read again, together with the discovery file, and +/// the proof is returned only when that second pair agrees with each other +/// and with `pid` and `epoch`. A proof from another boot is never returned. +#[must_use] +pub fn plan_proof_for_boot(run_dir: &Path, pid: u32, epoch: u64) -> Option { + if let Some(proof) = proof_matching(run_dir, pid, epoch) { + return Some(proof); + } + let discovery = GatewayDiscoveryFile::read(run_dir).ok().flatten()?; + let private = GatewayPrivateFile::read(run_dir).ok().flatten()?; + paired_proof(&private, &discovery, pid, epoch) +} + +/// The private file's plan proof when that file itself names `pid` and +/// `epoch`. An unreadable file is `None`, the same as a missing one. +fn proof_matching(run_dir: &Path, pid: u32, epoch: u64) -> Option { + let private = GatewayPrivateFile::read(run_dir).ok().flatten()?; + (private.pid == pid && private.epoch == epoch).then_some(private.plan_proof) +} + +/// The plan proof when `private` and `discovery` name one boot and that +/// boot is `pid` and `epoch`. +fn paired_proof( + private: &GatewayPrivateFile, + discovery: &GatewayDiscoveryFile, + pid: u32, + epoch: u64, +) -> Option { + if private.names_boot_of(discovery) && discovery.pid == pid && discovery.epoch == epoch { + Some(private.plan_proof.clone()) + } else { + None + } +} + +#[cfg(test)] +#[path = "private-tests.rs"] +mod tests; diff --git a/crates/workshop/gateway/src/account-tests.rs b/crates/workshop/gateway/src/account-tests.rs new file mode 100644 index 00000000..56b59669 --- /dev/null +++ b/crates/workshop/gateway/src/account-tests.rs @@ -0,0 +1,187 @@ +use std::io::{Read as _, Write as _}; + +use workshop_gateway_discovery::{GatewayDiscoveryFile, GatewayPrivateFile, SidecarError}; + +use super::{ + BootSecrets, ChatgptAccount, ChatgptAccountError, ChatgptAccountOp, ChatgptJsonFailure, + ChatgptReadTarget, ChatgptSidecarFailure, authorize_url_from_body, +}; + +const fn assert_copy() {} + +#[test] +fn account_error_types_are_copy_and_boot_secrets_redact_debug() { + assert_copy::(); + assert_copy::(); + assert_copy::(); + assert_copy::(); + assert_copy::(); + assert_copy::(); + assert_eq!( + ChatgptSidecarFailure::from_sidecar(&SidecarError::Canceled), + ChatgptSidecarFailure::Other + ); + + let secrets = BootSecrets { + base_url: "http://127.0.0.1:41234".to_owned(), + api_key: "secret-api-bearer".to_owned(), + owner_proof: "secret-owner-proof".to_owned(), + }; + let formatted = format!("{secrets:?}"); + assert!(formatted.contains("127.0.0.1:41234"), "{formatted}"); + assert!(formatted.contains(""), "{formatted}"); + assert!(!formatted.contains("secret-api-bearer"), "{formatted}"); + assert!(!formatted.contains("secret-owner-proof"), "{formatted}"); +} + +#[test] +fn authorize_url_parser_extracts_non_empty_url_or_returns_closed_error() { + let url = + authorize_url_from_body(r#"{"authorize_url":"https://auth.example/go"}"#).expect("url"); + assert_eq!(url, "https://auth.example/go"); + + assert_eq!( + authorize_url_from_body(r#"{"authorize_url":""}"#), + Err(ChatgptAccountError::SignInMissingUrl) + ); + assert!(matches!( + authorize_url_from_body(r#"{"authorize_url":"https://auth.example/unterminated"#), + Err(ChatgptAccountError::SignInInvalidJson( + ChatgptJsonFailure::Eof { .. } + )) + )); +} + +#[tokio::test] +async fn boot_secrets_reject_missing_and_mismatched_sidecars() { + let empty = tempfile::tempdir().expect("tempdir"); + assert_eq!( + ChatgptAccount::new(empty.path()).status().await, + Err(ChatgptAccountError::DiscoveryMissing) + ); + + let dir = tempfile::tempdir().expect("tempdir"); + let discovery = GatewayDiscoveryFile { + port: 41235, + api_key: "loopback-bearer-secret".to_owned(), + pid: 42, + epoch: 99, + version: "test".to_owned(), + started_at: "1970-01-01T00:00:09Z".to_owned(), + }; + std::fs::write( + dir.path().join("gateway.json"), + serde_json::to_vec(&discovery).expect("discovery json"), + ) + .expect("write discovery"); + + assert_eq!( + ChatgptAccount::new(dir.path()).status().await, + Err(ChatgptAccountError::OwnerProofMissing) + ); + + let stale_private = GatewayPrivateFile { + pid: 42, + epoch: 100, + plan_proof: "plan-proof".to_owned(), + owner_proof: "owner-proof-secret".to_owned(), + }; + std::fs::write( + dir.path().join("gateway-private.json"), + serde_json::to_vec(&stale_private).expect("stale private json"), + ) + .expect("write stale private"); + assert_eq!( + ChatgptAccount::new(dir.path()).status().await, + Err(ChatgptAccountError::BootMismatch { + discovery_pid: 42, + discovery_epoch: 99, + private_pid: 42, + private_epoch: 100, + }) + ); +} + +#[tokio::test] +async fn loopback_requests_attach_owner_proof_and_refuse_redirects() { + let target_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind redirect canary"); + target_listener + .set_nonblocking(true) + .expect("nonblocking canary"); + let target_port = target_listener.local_addr().expect("canary addr").port(); + + let gateway_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fake gateway"); + let gateway_port = gateway_listener.local_addr().expect("gateway addr").port(); + let server = std::thread::spawn(move || { + let (mut sock, _) = gateway_listener.accept().expect("accept request"); + let mut buf = [0_u8; 4096]; + let mut filled = 0; + while filled < buf.len() { + let read = sock.read(&mut buf[filled..]).unwrap_or(0); + if read == 0 { + break; + } + filled += read; + if buf[..filled].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let req_text = String::from_utf8_lossy(&buf[..filled]).into_owned(); + let reply = format!( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{target_port}/steal\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = sock.write_all(reply.as_bytes()); + req_text + }); + + let dir = tempfile::tempdir().expect("tempdir"); + let discovery = GatewayDiscoveryFile { + port: gateway_port, + api_key: "loopback-bearer-secret".to_owned(), + pid: 42, + epoch: 99, + version: "test".to_owned(), + started_at: "1970-01-01T00:00:09Z".to_owned(), + }; + let matching_private = GatewayPrivateFile { + pid: 42, + epoch: 99, + plan_proof: "plan-proof".to_owned(), + owner_proof: "owner-proof-secret".to_owned(), + }; + std::fs::write( + dir.path().join("gateway.json"), + serde_json::to_vec(&discovery).expect("discovery json"), + ) + .expect("write discovery"); + std::fs::write( + dir.path().join("gateway-private.json"), + serde_json::to_vec(&matching_private).expect("matching private json"), + ) + .expect("write matching private"); + + let status_result = ChatgptAccount::new(dir.path()).status().await; + assert_eq!( + status_result, + Err(ChatgptAccountError::HttpStatus { + op: ChatgptAccountOp::Status, + status: 302, + }) + ); + let captured_headers = server + .join() + .expect("join fake gateway") + .to_ascii_lowercase(); + assert!( + captured_headers.contains("authorization: bearer loopback-bearer-secret"), + "{captured_headers}" + ); + assert!( + captured_headers.contains("x-runvil-owner-proof: owner-proof-secret"), + "{captured_headers}" + ); + assert!( + target_listener.accept().is_err(), + "redirect target must never be contacted with Gateway secrets" + ); +} diff --git a/crates/workshop/gateway/src/account.rs b/crates/workshop/gateway/src/account.rs new file mode 100644 index 00000000..ed822355 --- /dev/null +++ b/crates/workshop/gateway/src/account.rs @@ -0,0 +1,505 @@ +//! Loopback HTTP calls to the local Gateway's `/admin/chatgpt/*` endpoints. +//! +//! Every request authenticates with two secrets from `run_dir`: the `api_key` +//! bearer from `gateway.json` and the `owner_proof` header from +//! `gateway-private.json`. [`boot_secrets`] verifies via +//! [`GatewayPrivateFile::names_boot_of`] that both files came from the same +//! `(pid, epoch)` Gateway boot so stale secrets from a previous process are +//! rejected before any request leaves this process. +//! +//! Admin requests use [`http_client`], which turns off HTTP redirects and +//! system proxies so bearer and owner-proof headers stay strictly on loopback +//! `127.0.0.1`. Sign-in passes the OAuth launch URL only to the caller's native +//! opener closure and discards both the URL and any raw opener error afterwards. +//! +//! [`ChatgptAccount::status`], [`ChatgptAccount::sign_in`], +//! [`ChatgptAccount::sign_out`], and [`ChatgptAccount::reset`] return the +//! Gateway's status JSON as received, and [`ChatgptAccount::enable_models`] +//! returns the HTTP status and body as received; the desktop app projects both +//! before anything reaches the page. Every failure from this module surfaces as +//! a `Copy` [`ChatgptAccountError`] composed solely of closed enum tags and +//! numeric identifiers, carrying no external text, paths, or secrets. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use workshop_gateway_discovery::{ + GatewayDiscoveryFile, GatewayPrivateFile, SidecarError, default_run_dir, +}; + +/// Header the Gateway's owner check reads on `/admin/chatgpt/*`. Not a bearer. +const OWNER_HEADER: &str = "x-runvil-owner-proof"; + +/// Closed `/admin/chatgpt/*` operation identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatgptAccountOp { + /// `GET /admin/chatgpt/status`. + Status, + /// `POST /admin/chatgpt/sign-in`. + SignIn, + /// `POST /admin/chatgpt/sign-out`. + SignOut, + /// `POST /admin/chatgpt/reset`. + Reset, + /// `POST /admin/chatgpt/models/enable`. + EnableModels, +} + +impl ChatgptAccountOp { + const fn request_parts(self) -> (reqwest::Method, &'static str) { + match self { + Self::Status => (reqwest::Method::GET, "/admin/chatgpt/status"), + Self::SignIn => (reqwest::Method::POST, "/admin/chatgpt/sign-in"), + Self::SignOut => (reqwest::Method::POST, "/admin/chatgpt/sign-out"), + Self::Reset => (reqwest::Method::POST, "/admin/chatgpt/reset"), + Self::EnableModels => (reqwest::Method::POST, "/admin/chatgpt/models/enable"), + } + } +} + +/// Closed response-body read target for `/admin/chatgpt/*` calls that read a body. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatgptReadTarget { + /// `GET /admin/chatgpt/status` reply body. + Status, + /// `POST /admin/chatgpt/sign-in` reply body. + SignIn, + /// `POST /admin/chatgpt/models/enable` reply body. + EnableModels, +} + +/// Closed `serde_json` failure classification without offending value text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatgptJsonFailure { + /// Underlying reader or writer I/O error. + Io { + /// 1-based line number, or `0` when unavailable. + line: usize, + /// 1-based column number, or `0` when unavailable. + column: usize, + }, + /// Malformed JSON syntax. + Syntax { + /// 1-based line number, or `0` when unavailable. + line: usize, + /// 1-based column number, or `0` when unavailable. + column: usize, + }, + /// Valid JSON syntax whose types or fields did not match the target shape. + Data { + /// 1-based line number, or `0` when unavailable. + line: usize, + /// 1-based column number, or `0` when unavailable. + column: usize, + }, + /// Input ended before a complete JSON value was read. + Eof { + /// 1-based line number, or `0` when unavailable. + line: usize, + /// 1-based column number, or `0` when unavailable. + column: usize, + }, +} + +impl ChatgptJsonFailure { + /// Classifies `error` using only its category and line/column coordinates, + /// never invoking `Display` (which can echo offending string values). + #[must_use] + pub fn from_serde(error: &serde_json::Error) -> Self { + let line = error.line(); + let column = error.column(); + match error.classify() { + serde_json::error::Category::Io => Self::Io { line, column }, + serde_json::error::Category::Syntax => Self::Syntax { line, column }, + serde_json::error::Category::Data => Self::Data { line, column }, + serde_json::error::Category::Eof => Self::Eof { line, column }, + } + } +} + +/// Closed [`SidecarError`] classification for sidecar file reads without +/// filesystem paths or raw OS/JSON strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatgptSidecarFailure { + /// Sidecar file existed but could not be read. + Read, + /// Sidecar file was not valid JSON for its schema. + Parse(ChatgptJsonFailure), + /// Sidecar file failed structural validation. + Invalid, + /// Any other `SidecarError` variant. + Other, +} + +impl ChatgptSidecarFailure { + fn from_sidecar(error: &SidecarError) -> Self { + match error { + SidecarError::Read { .. } => Self::Read, + SidecarError::Parse { source, .. } => { + Self::Parse(ChatgptJsonFailure::from_serde(source)) + } + SidecarError::Invalid { .. } => Self::Invalid, + _ => Self::Other, + } + } +} + +/// Closed [`reqwest::Error`] classification without URLs or external `Display` text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatgptHttpFailure { + /// Request or body read timed out. + Timeout, + /// TCP loopback connection failed. + Connect, + /// Response body decoding failed. + Decode, + /// Request or response body transfer failed. + Body, + /// Redirect policy blocked a redirect. + Redirect, + /// HTTP client builder failed to initialize. + Builder, + /// Outbound HTTP request failed to construct. + Request, + /// Other transport failure. + Transport, +} + +impl ChatgptHttpFailure { + fn from_reqwest(error: &reqwest::Error) -> Self { + if error.is_timeout() { + Self::Timeout + } else if error.is_connect() { + Self::Connect + } else if error.is_decode() { + Self::Decode + } else if error.is_body() { + Self::Body + } else if error.is_redirect() { + Self::Redirect + } else if error.is_builder() { + Self::Builder + } else if error.is_request() { + Self::Request + } else { + Self::Transport + } + } +} + +/// Closed, `Copy` failure enum for `/admin/chatgpt/*` operations. +/// +/// Contains only closed enum categories and integer status/boot/coordinate +/// fields, so no external text, secret, URL, or filesystem path can be carried +/// in an error value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatgptAccountError { + /// `gateway.json` was absent from `run_dir`. + DiscoveryMissing, + /// `gateway.json` could not be read or validated. + DiscoveryUnreadable(ChatgptSidecarFailure), + /// `gateway-private.json` was absent from `run_dir`. + OwnerProofMissing, + /// `gateway-private.json` could not be read or validated. + OwnerProofUnreadable(ChatgptSidecarFailure), + /// `gateway-private.json` named a different `(pid, epoch)` boot than `gateway.json`. + BootMismatch { + /// Active `gateway.json` process ID. + discovery_pid: u32, + /// Active `gateway.json` boot epoch. + discovery_epoch: u64, + /// Stale `gateway-private.json` process ID. + private_pid: u32, + /// Stale `gateway-private.json` boot epoch. + private_epoch: u64, + }, + /// Loopback HTTP client failed to initialize. + ClientBuild(ChatgptHttpFailure), + /// Sending the loopback HTTP request failed. + Request { + /// Closed admin operation identifier. + op: ChatgptAccountOp, + /// Closed transport failure category. + kind: ChatgptHttpFailure, + }, + /// Reading the HTTP response body failed. + ReadBody { + /// Closed response-body target identifier. + target: ChatgptReadTarget, + /// HTTP status code received before the body failed. + status: u16, + /// Closed transport failure category. + kind: ChatgptHttpFailure, + }, + /// Endpoint returned a non-2xx HTTP status where 2xx was required. + HttpStatus { + /// Closed admin operation identifier. + op: ChatgptAccountOp, + /// Non-2xx HTTP status code. + status: u16, + }, + /// `GET /admin/chatgpt/status` body was not valid JSON. + StatusInvalidJson(ChatgptJsonFailure), + /// `POST /admin/chatgpt/sign-in` body was not valid JSON. + SignInInvalidJson(ChatgptJsonFailure), + /// `POST /admin/chatgpt/sign-in` JSON omitted a non-empty `authorize_url` string. + SignInMissingUrl, + /// Native system browser opener returned an error. + BrowserOpen, +} + +/// Process-local loopback base URL, API bearer, and owner proof for one call. +struct BootSecrets { + base_url: String, + api_key: String, + owner_proof: String, +} + +impl fmt::Debug for BootSecrets { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BootSecrets") + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("owner_proof", &"") + .finish() + } +} + +fn boot_secrets(run_dir: &Path) -> Result { + let discovery = match GatewayDiscoveryFile::read(run_dir) { + Ok(Some(discovery)) => discovery, + Ok(None) => return Err(ChatgptAccountError::DiscoveryMissing), + Err(error) => { + return Err(ChatgptAccountError::DiscoveryUnreadable( + ChatgptSidecarFailure::from_sidecar(&error), + )); + } + }; + let private = match GatewayPrivateFile::read(run_dir) { + Ok(Some(private)) => private, + Ok(None) => return Err(ChatgptAccountError::OwnerProofMissing), + Err(error) => { + return Err(ChatgptAccountError::OwnerProofUnreadable( + ChatgptSidecarFailure::from_sidecar(&error), + )); + } + }; + if !private.names_boot_of(&discovery) { + return Err(ChatgptAccountError::BootMismatch { + discovery_pid: discovery.pid, + discovery_epoch: discovery.epoch, + private_pid: private.pid, + private_epoch: private.epoch, + }); + } + Ok(BootSecrets { + base_url: format!("http://127.0.0.1:{}", discovery.port), + api_key: discovery.api_key, + owner_proof: private.owner_proof, + }) +} + +fn http_client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .map_err(|error| ChatgptAccountError::ClientBuild(ChatgptHttpFailure::from_reqwest(&error))) +} + +async fn gateway_call( + op: ChatgptAccountOp, + secrets: &BootSecrets, +) -> Result { + let (method, path) = op.request_parts(); + http_client()? + .request(method, format!("{}{path}", secrets.base_url)) + .bearer_auth(&secrets.api_key) + .header(OWNER_HEADER, &secrets.owner_proof) + .send() + .await + .map_err(|error| ChatgptAccountError::Request { + op, + kind: ChatgptHttpFailure::from_reqwest(&error), + }) +} + +/// Extracts the `authorize_url` from a sign-in reply body for the native +/// browser opener only. +fn authorize_url_from_body(body: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(body).map_err(|error| { + ChatgptAccountError::SignInInvalidJson(ChatgptJsonFailure::from_serde(&error)) + })?; + value + .get("authorize_url") + .and_then(serde_json::Value::as_str) + .filter(|url| !url.is_empty()) + .map(str::to_owned) + .ok_or(ChatgptAccountError::SignInMissingUrl) +} + +/// Loopback caller for the local Gateway's `/admin/chatgpt/*` endpoints. +/// +/// Reads `gateway.json` and `gateway-private.json` from `run_dir` on each +/// operation, verifies matching `(pid, epoch)` boot identity, and keeps both +/// `api_key` and `owner_proof` strictly inside `workshop-gateway`. Successful +/// calls return the Gateway's status JSON or `(status, body)` pair as received; +/// the desktop app projects those replies before anything reaches the page. +#[derive(Debug, Clone)] +pub struct ChatgptAccount { + run_dir: PathBuf, +} + +impl ChatgptAccount { + /// Takes an explicit run directory. + #[must_use] + pub fn new(run_dir: impl Into) -> Self { + Self { + run_dir: run_dir.into(), + } + } + + /// Binds account operations to the platform default Gateway `run_dir`, + /// when a user run directory resolves. + #[must_use] + pub fn from_default_run_dir() -> Option { + default_run_dir().map(Self::new) + } + + /// Calls `GET /admin/chatgpt/status` and returns the Gateway's status JSON + /// as received, for the desktop app to project before anything reaches the + /// page. + /// + /// # Errors + /// Returns [`ChatgptAccountError`] if sidecar secrets are missing or + /// mismatched, transport fails, the Gateway answers non-2xx, or the body + /// is not valid JSON. + pub async fn status(&self) -> Result { + let secrets = boot_secrets(&self.run_dir)?; + let op = ChatgptAccountOp::Status; + let response = gateway_call(op, &secrets).await?; + let status = response.status(); + let status_code = status.as_u16(); + let body_text = response + .text() + .await + .map_err(|error| ChatgptAccountError::ReadBody { + target: ChatgptReadTarget::Status, + status: status_code, + kind: ChatgptHttpFailure::from_reqwest(&error), + })?; + if !status.is_success() { + return Err(ChatgptAccountError::HttpStatus { + op, + status: status_code, + }); + } + serde_json::from_str(&body_text).map_err(|error| { + ChatgptAccountError::StatusInvalidJson(ChatgptJsonFailure::from_serde(&error)) + }) + } + + /// Starts an OAuth sign-in via `POST /admin/chatgpt/sign-in`. + /// + /// The URL parsed from the sign-in reply is passed to `open` and used for + /// nothing else, and an error from `open` is discarded. The status JSON + /// that follows is returned as received, and the desktop app projects it. + /// + /// # Errors + /// Returns [`ChatgptAccountError`] if sidecar verification, HTTP transport, + /// sign-in payload parsing, browser launch, or the follow-up status read + /// fails. + pub async fn sign_in(&self, open: F) -> Result + where + F: FnOnce(&str) -> Result<(), E>, + { + let secrets = boot_secrets(&self.run_dir)?; + let op = ChatgptAccountOp::SignIn; + let response = gateway_call(op, &secrets).await?; + let status = response.status(); + let status_code = status.as_u16(); + let body = response + .text() + .await + .map_err(|error| ChatgptAccountError::ReadBody { + target: ChatgptReadTarget::SignIn, + status: status_code, + kind: ChatgptHttpFailure::from_reqwest(&error), + })?; + if !status.is_success() { + return Err(ChatgptAccountError::HttpStatus { + op, + status: status_code, + }); + } + let url = authorize_url_from_body(&body)?; + if open(&url).is_err() { + return Err(ChatgptAccountError::BrowserOpen); + } + self.status().await + } + + /// Signs out the active ChatGPT session via `POST /admin/chatgpt/sign-out` + /// and returns the Gateway's refreshed status JSON as received, for the + /// desktop app to project before anything reaches the page. + /// + /// # Errors + /// Returns [`ChatgptAccountError`] if sidecar verification, transport, + /// non-2xx response status, or the follow-up status read fails. + pub async fn sign_out(&self) -> Result { + self.post_then_status(ChatgptAccountOp::SignOut).await + } + + /// Resets persisted ChatGPT sign-in state via `POST /admin/chatgpt/reset` + /// and returns the Gateway's refreshed status JSON as received, for the + /// desktop app to project before anything reaches the page. + /// + /// # Errors + /// Returns [`ChatgptAccountError`] if sidecar verification, transport, + /// non-2xx response status, or the follow-up status read fails. + pub async fn reset(&self) -> Result { + self.post_then_status(ChatgptAccountOp::Reset).await + } + + /// Calls `POST /admin/chatgpt/models/enable` and returns the HTTP status + /// and body as received, for the desktop app to project before anything + /// reaches the page. + /// + /// # Errors + /// Returns [`ChatgptAccountError`] if sidecar verification, request + /// dispatch, or reading the response body fails. + pub async fn enable_models(&self) -> Result<(u16, String), ChatgptAccountError> { + let secrets = boot_secrets(&self.run_dir)?; + let response = gateway_call(ChatgptAccountOp::EnableModels, &secrets).await?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| ChatgptAccountError::ReadBody { + target: ChatgptReadTarget::EnableModels, + status, + kind: ChatgptHttpFailure::from_reqwest(&error), + })?; + Ok((status, body)) + } + + async fn post_then_status( + &self, + op: ChatgptAccountOp, + ) -> Result { + let secrets = boot_secrets(&self.run_dir)?; + let response = gateway_call(op, &secrets).await?; + let status = response.status(); + if !status.is_success() { + return Err(ChatgptAccountError::HttpStatus { + op, + status: status.as_u16(), + }); + } + self.status().await + } +} + +#[cfg(test)] +#[path = "account-tests.rs"] +mod tests; diff --git a/crates/workshop/gateway/src/binding.rs b/crates/workshop/gateway/src/binding.rs index ed4ff4d7..b58af840 100644 --- a/crates/workshop/gateway/src/binding.rs +++ b/crates/workshop/gateway/src/binding.rs @@ -68,6 +68,13 @@ impl GatewaySnapshot { pub fn generation(&self) -> u64 { self.generation } + + /// The plan proof this generation sends on the model list, when one + /// was paired with the boot. + #[must_use] + pub fn plan_proof(&self) -> Option<&str> { + self.client.plan_proof() + } } /// Shared atomic gateway snapshot and replacement notification. @@ -124,9 +131,23 @@ impl GatewayBinding { base_url: &str, api_key: &str, identity: Option, + ) -> Result { + Self::new_with_plan_proof(base_url, api_key, identity, None) + } + + /// Builds generation zero, and when `plan_proof` is set the model list + /// carries it. Absent leaves every request as it is today. + /// + /// # Errors + /// Returns [`GatewayError::Build`] if the HTTP client cannot be built. + pub fn new_with_plan_proof( + base_url: &str, + api_key: &str, + identity: Option, + plan_proof: Option<&str>, ) -> Result { Ok(Self::from_snapshot(build_snapshot( - base_url, api_key, identity, + base_url, api_key, identity, plan_proof, )?)) } @@ -191,7 +212,7 @@ impl GatewayBinding { api_key: &str, identity: Option, ) -> Result<(), GatewayPublicationError> { - let snapshot = build_snapshot(base_url, api_key, identity)?; + let snapshot = build_snapshot(base_url, api_key, identity, None)?; self.publish_snapshot(snapshot) } @@ -319,9 +340,14 @@ fn build_snapshot( base_url: &str, api_key: &str, identity: Option, + plan_proof: Option<&str>, ) -> Result { + let mut client = GatewayClient::new(base_url, api_key)?; + if let Some(proof) = plan_proof { + client = client.with_plan_proof(proof); + } Ok(GatewaySnapshot { - client: GatewayClient::new(base_url, api_key)?, + client, generation: 0, identity, }) diff --git a/crates/workshop/gateway/src/binding/publication.rs b/crates/workshop/gateway/src/binding/publication.rs index d1f1b0f0..7034a99b 100644 --- a/crates/workshop/gateway/src/binding/publication.rs +++ b/crates/workshop/gateway/src/binding/publication.rs @@ -40,7 +40,10 @@ impl GatewayBinding { Ok(false) }; } - let snapshot = build_snapshot(base_url, api_key, Some(identity))?; + let plan_proof = crate::discovery::default_run_dir().and_then(|run_dir| { + crate::discovery::plan_proof_for_boot(&run_dir, identity.pid(), identity.epoch()) + }); + let snapshot = build_snapshot(base_url, api_key, Some(identity), plan_proof.as_deref())?; cancellation .run_if_active(|| { let mut publication = loop { diff --git a/crates/workshop/gateway/src/client.rs b/crates/workshop/gateway/src/client.rs index 66615b31..38a3e688 100644 --- a/crates/workshop/gateway/src/client.rs +++ b/crates/workshop/gateway/src/client.rs @@ -30,6 +30,9 @@ pub(crate) const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2); /// on Linux, ~75 s on Windows). const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Header a Host-supplied plan proof is sent in, and only on the model list. +const PLAN_PROOF_HEADER: &str = "X-Runvil-Plan-Proof"; + /// Default whole-request timeout for non-streaming operations: the model /// catalog fetch and the profile selection. Forwarded requests and the /// progress subscription can legitimately run for minutes, so the same @@ -101,6 +104,8 @@ pub struct GatewayClient { http: reqwest::Client, pub(crate) base_url: String, pub(crate) api_key: String, + /// Plan proof sent on `GET /v1/models` only. `None` sends no such header. + plan_proof: Option, /// Whole-request bound for buffered calls; header-phase bound for /// streaming calls. request_timeout: Duration, @@ -114,6 +119,7 @@ impl fmt::Debug for GatewayClient { f.debug_struct("GatewayClient") .field("base_url", &self.base_url) .field("api_key", &"") + .field("plan_proof", &"") .finish_non_exhaustive() } } @@ -125,17 +131,30 @@ impl GatewayClient { /// An empty `api_key` disables authentication: requests then omit the /// `Authorization` header. /// + /// A loopback `base_url` is contacted directly. A redirect comes back as + /// its own status, and the process proxy is not used. Any other authority + /// keeps reqwest's defaults. + /// /// # Errors /// Returns [`GatewayError::Build`] if the TLS backend cannot initialize. pub fn new(base_url: &str, api_key: &str) -> Result { - let http = reqwest::Client::builder() - .connect_timeout(CONNECT_TIMEOUT) + // A loopback Gateway is that address. A redirect or a proxy would + // carry the bearer, and the plan proof when one is set, to somewhere + // else. Any other authority keeps redirects and the process proxy. + let mut builder = reqwest::Client::builder().connect_timeout(CONNECT_TIMEOUT); + if workshop_support::is_loopback_http_url(base_url) { + builder = builder + .redirect(reqwest::redirect::Policy::none()) + .no_proxy(); + } + let http = builder .build() .map_err(|source| GatewayError::Build(Box::new(source)))?; Ok(Self { http, base_url: base_url.trim_end_matches('/').to_string(), api_key: api_key.to_string(), + plan_proof: None, request_timeout: REQUEST_TIMEOUT, health_timeout: HEALTH_PROBE_TIMEOUT, }) @@ -180,6 +199,25 @@ impl GatewayClient { } } + /// Sends `plan_proof` as `X-Runvil-Plan-Proof` on the model list only. + /// + /// An empty proof is ignored, so the request stays as it is today. + /// Health, profile, status, and forwarded admin calls never carry it. + #[must_use] + pub fn with_plan_proof(mut self, plan_proof: impl Into) -> Self { + let proof = plan_proof.into(); + if !proof.is_empty() { + self.plan_proof = Some(proof); + } + self + } + + /// The plan proof this client sends on the model list, when one was set. + #[must_use] + pub fn plan_proof(&self) -> Option<&str> { + self.plan_proof.as_deref() + } + /// The gateway's base URL as configured, trailing slash trimmed - /// also the origin the config-panel iframe loads from. #[must_use] @@ -260,7 +298,16 @@ impl GatewayClient { /// completed and [`GatewayError::ReadBody`] if the response body cannot /// be read. pub async fn list_models(&self) -> Result { - self.get_json("/v1/models").await + let mut request = self.authorize(self.http.get(format!("{}/v1/models", self.base_url))); + if let Some(proof) = &self.plan_proof { + request = request.header(PLAN_PROOF_HEADER, proof); + } + let response = request + .timeout(self.request_timeout) + .send() + .await + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + read(response).await } /// Fetches the gateway's profile list from `GET /admin/profiles`. diff --git a/crates/workshop/gateway/src/client/tests.rs b/crates/workshop/gateway/src/client/tests.rs index 527976c6..753edcda 100644 --- a/crates/workshop/gateway/src/client/tests.rs +++ b/crates/workshop/gateway/src/client/tests.rs @@ -23,9 +23,113 @@ fn trailing_slash_is_trimmed_from_base_url() { #[test] fn debug_redacts_the_api_key() { - let client = GatewayClient::new("http://127.0.0.1:8081", "secret-key").expect("client"); + let client = GatewayClient::new("http://127.0.0.1:8081", "secret-key") + .expect("client") + .with_plan_proof("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); let rendered = format!("{client:?}"); assert!(!rendered.contains("secret-key"), "key leaked: {rendered}"); + assert!( + !rendered.contains("0123456789abcdef"), + "plan proof leaked: {rendered}" + ); + assert!( + !rendered.contains("Some") && !rendered.contains("None"), + "presence of the proof must not leak: {rendered}" + ); +} + +#[tokio::test] +async fn the_plan_proof_is_sent_on_the_model_list_and_nowhere_else() { + use std::sync::{Arc, Mutex}; + + const PROOF: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + fn echo(route: &'static str) -> axum::routing::MethodRouter { + axum::routing::get(move |headers: axum::http::HeaderMap| async move { + let proof = headers + .get("x-runvil-plan-proof") + .and_then(|value| value.to_str().ok()) + .unwrap_or("-"); + let count = headers.get_all("x-runvil-plan-proof").iter().count(); + format!("{route} {count} {proof}") + }) + } + let other: Arc>> = Arc::new(Mutex::new(Vec::new())); + let health_slot = Arc::clone(&other); + let forward_slot = Arc::clone(&other); + let app = axum::Router::new() + .route("/v1/models", echo("models")) + .route("/admin/profiles", echo("profiles")) + .route("/admin/status", echo("status")) + .route( + "/health", + axum::routing::get(move |headers: axum::http::HeaderMap| { + let health_slot = Arc::clone(&health_slot); + async move { + health_slot.lock().expect("capture lock").extend( + headers + .get_all("x-runvil-plan-proof") + .iter() + .filter_map(|value| value.to_str().ok().map(str::to_owned)), + ); + "ok" + } + }), + ) + .route( + "/admin/config", + axum::routing::put(move |headers: axum::http::HeaderMap| { + let forward_slot = Arc::clone(&forward_slot); + async move { + forward_slot.lock().expect("capture lock").extend( + headers + .get_all("x-runvil-plan-proof") + .iter() + .filter_map(|value| value.to_str().ok().map(str::to_owned)), + ); + "forwarded" + } + }), + ); + let base = serve(app).await; + let client = GatewayClient::new(&base, "tok") + .expect("client builds") + .with_plan_proof(PROOF); + let models = client + .list_models() + .await + .expect("the model list completes"); + assert_eq!( + String::from_utf8_lossy(&models.body), + format!("models 1 {PROOF}") + ); + assert!( + !String::from_utf8_lossy(&models.body).contains("owner"), + "the model list body is the gateway's, not a proof" + ); + for (read, route) in [ + (client.list_profiles().await, "profiles"), + (client.profile_status().await, "status"), + ] { + let response = read.expect("the read completes"); + assert_eq!( + String::from_utf8_lossy(&response.body), + format!("{route} 0 -"), + "{route} must not carry the plan proof" + ); + } + assert!(client.health().await, "the health probe answers"); + let forwarded = client + .forward(reqwest::Method::PUT, "/admin/config", None) + .await + .expect("the forward completes"); + assert_eq!(String::from_utf8_lossy(&forwarded.body), "forwarded"); + assert!( + other.lock().expect("capture lock").is_empty(), + "health and the admin forward carry no plan proof" + ); + let plain = GatewayClient::new(&base, "tok").expect("client builds"); + let models = plain.list_models().await.expect("the model list completes"); + assert_eq!(String::from_utf8_lossy(&models.body), "models 0 -"); } #[tokio::test] diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs index c5d22fe2..65a0a09a 100644 --- a/crates/workshop/gateway/src/lib.rs +++ b/crates/workshop/gateway/src/lib.rs @@ -1,11 +1,12 @@ //! workshop-gateway - the gateway subsystem: the bearer-authenticated //! HTTP client for the Runvil gateway's OpenAI-compatible API -//! (`client`), the replaceable endpoint binding (`binding`) and -//! discovery-file resolution (`resolve`), the reachability heartbeat -//! (`heartbeat`), the catalog and profile refresh the heartbeat and the -//! server's profile switch share (`refresh`), and the gateway progress -//! subscriber (`progress`). Every production module is private, and -//! the crate root's re-exports are the one public path. +//! (`client`), the owner-authenticated ChatGPT account caller (`account`), +//! the replaceable endpoint binding (`binding`) and discovery-file +//! resolution (`resolve`), the reachability heartbeat (`heartbeat`), the +//! catalog and profile refresh the heartbeat and the server's profile +//! switch share (`refresh`), and the gateway progress subscriber +//! (`progress`). Every production module is private, and the crate root's +//! re-exports are the one public path. //! //! ## Invariants //! @@ -15,7 +16,17 @@ //! - No axum type appears in this crate's public API: the domain code //! speaks `reqwest` statuses and raw bodies, and the server maps them //! to HTTP responses. -//! - A bearer key is never written to logs or `Debug` output. +//! - A bearer key is never written to logs or `Debug` output. A plan proof +//! is redacted the same way: `GatewayClient` sends it only on +//! `GET /v1/models`, and `GatewaySnapshot` carries it so +//! `WorkshopBroker` can send it on model rounds as well. An owner proof +//! from `gateway-private.json` stays inside [`ChatgptAccount`]: it is +//! redacted in `Debug`, checked against `gateway.json`'s `(pid, epoch)` +//! boot identity before use, sent only over loopback `127.0.0.1` without +//! redirects or proxies, and never returned or carried in +//! [`ChatgptAccountError`]; [`ChatgptAccount`] returns the gateway's status +//! JSON and enable `(status, body)` replies as received, and the desktop app +//! projects both before anything reaches the page. //! - Workshop reaches model providers only through the gateway, so no //! vendor credential passes through this crate or reaches the page or //! Lua. @@ -28,6 +39,7 @@ //! family, and its field names are pinned by a golden JSON test so drift //! is caught. +mod account; mod binding; mod client; mod handles; @@ -39,6 +51,10 @@ mod resolve; pub mod test_gateway; mod version; +pub use account::{ + ChatgptAccount, ChatgptAccountError, ChatgptAccountOp, ChatgptHttpFailure, ChatgptJsonFailure, + ChatgptReadTarget, ChatgptSidecarFailure, +}; pub use binding::{GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater}; pub use client::{ ForwardedResponse, GatewayClient, GatewayError, GatewayRealtimeSocket, GatewayResponse, diff --git a/crates/workshop/gateway/src/resolve-tests.rs b/crates/workshop/gateway/src/resolve-tests.rs index 7df81781..6e6fce38 100644 --- a/crates/workshop/gateway/src/resolve-tests.rs +++ b/crates/workshop/gateway/src/resolve-tests.rs @@ -307,6 +307,9 @@ fn the_report_names_the_winning_source_and_a_removed_file() { base_url: "http://127.0.0.1:4000".to_owned(), api_key: "k".to_owned(), identity: None, + plan_proof: Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(), + ), source: GatewaySource::Config, stale: Some(StaleReason::KeyRejected), }; @@ -327,6 +330,11 @@ fn the_report_names_the_winning_source_and_a_removed_file() { "the endpoint and its source are named: {}", connecting.description ); + let proof = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + assert!( + !stale_frame.description.contains(proof) && !connecting.description.contains(proof), + "the plan proof is not a status frame" + ); } #[test] @@ -335,9 +343,16 @@ fn debug_redacts_the_api_key() { base_url: "http://127.0.0.1:4000".to_owned(), api_key: "secret-key".to_owned(), identity: None, + plan_proof: Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(), + ), source: GatewaySource::Config, stale: None, }; let rendered = format!("{resolved:?}"); assert!(!rendered.contains("secret-key"), "key leaked: {rendered}"); + assert!( + !rendered.contains("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + "plan proof leaked: {rendered}" + ); } diff --git a/crates/workshop/gateway/src/resolve.rs b/crates/workshop/gateway/src/resolve.rs index ca2ca4d0..9012354f 100644 --- a/crates/workshop/gateway/src/resolve.rs +++ b/crates/workshop/gateway/src/resolve.rs @@ -25,6 +25,9 @@ pub struct ResolvedGateway { base_url: String, api_key: String, identity: Option, + /// Plan proof paired with `identity`, or `None` when this resolution + /// has no boot to pair or the private file did not name that boot. + plan_proof: Option, source: GatewaySource, stale: Option, } @@ -35,6 +38,7 @@ impl fmt::Debug for ResolvedGateway { f.debug_struct("ResolvedGateway") .field("base_url", &self.base_url) .field("api_key", &"") + .field("plan_proof", &"") .field("identity", &self.identity) .field("source", &self.source) .field("stale", &self.stale) @@ -62,6 +66,7 @@ impl ResolvedGateway { base_url: config.base_url.clone(), api_key: config.api_key.clone(), identity: None, + plan_proof: None, source: GatewaySource::Config, stale: None, } @@ -76,6 +81,7 @@ impl ResolvedGateway { base_url: format!("http://127.0.0.1:{}", identity.port()), api_key: identity.api_key().to_owned(), identity: Some(identity), + plan_proof: None, source: GatewaySource::GatewayDiscoveryFile, stale: None, } @@ -112,6 +118,12 @@ impl ResolvedGateway { self.identity.as_ref() } + /// The plan proof paired with the winning boot, when one was. + #[must_use] + pub fn plan_proof(&self) -> Option<&str> { + self.plan_proof.as_deref() + } + /// Which source won the resolution. #[cfg(test)] #[must_use] @@ -208,10 +220,16 @@ fn resolve_with( match probe(run_dir) { Ok(Resolution::Attach(file)) => match validate_resolved(file) { Ok(identity) => { + let plan_proof = crate::discovery::plan_proof_for_boot( + run_dir, + identity.pid(), + identity.epoch(), + ); return Ok(ResolvedGateway { base_url: format!("http://127.0.0.1:{}", identity.port()), api_key: identity.api_key().to_owned(), identity: Some(identity), + plan_proof, source: GatewaySource::GatewayDiscoveryFile, stale: None, }); @@ -239,6 +257,7 @@ fn resolve_with( base_url: config.base_url.clone(), api_key: config.api_key.clone(), identity: None, + plan_proof: None, source: GatewaySource::Config, stale, }); diff --git a/crates/workshop/gateway/tests/loopback_direct.rs b/crates/workshop/gateway/tests/loopback_direct.rs new file mode 100644 index 00000000..617d8245 --- /dev/null +++ b/crates/workshop/gateway/tests/loopback_direct.rs @@ -0,0 +1,229 @@ +//! The menu client's loopback Gateway is contacted directly. A redirect is +//! not followed, and an `HTTP_PROXY` listener receives nothing beyond the +//! canary that proves the variable is live. + +// clippy.toml's allow-expect-in-tests covers #[test] functions only, not +// integration-test helpers. Failing one of these by panicking names the +// invariant the helper exists to check. +#![expect( + clippy::expect_used, + reason = "test helpers fail by panicking with the invariant named" +)] +#![expect( + clippy::unwrap_used, + reason = "test helpers fail by panicking with the invariant named" +)] + +use std::io::{Read, Write}; +use std::process::{Command, ExitStatus}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use axum::Router; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::get; +use workshop_gateway::GatewayClient; + +const PROOF: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const ROLE: &str = "LOOPBACK_DIRECT_ROLE"; +const SIGNAL: &str = "LOOPBACK_DIRECT_SIGNAL"; + +async fn bind() -> (String, tokio::net::TcpListener) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + (format!("http://{addr}"), listener) +} + +fn spawn(listener: tokio::net::TcpListener, app: Router) { + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); +} + +fn menu(base: &str) -> GatewayClient { + GatewayClient::new(base, "tok") + .expect("client") + .with_plan_proof(PROOF) +} + +fn presented_proof(headers: &HeaderMap) -> bool { + headers + .get("x-runvil-plan-proof") + .and_then(|value| value.to_str().ok()) + == Some(PROOF) +} + +async fn proxy_listener() -> (String, Arc) { + let hits = Arc::new(AtomicUsize::new(0)); + let recorded = Arc::clone(&hits); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { + break; + }; + recorded.fetch_add(1, Ordering::SeqCst); + drop(socket); + } + }); + (format!("http://{addr}"), hits) +} + +async fn canary_hits(listener: tokio::net::TcpListener, hits: Arc) -> usize { + let (socket, _) = listener.accept().await.expect("the child marks the canary"); + let _ = socket.readable().await; + let mut buf = [0_u8; 1]; + let _ = socket.try_read(&mut buf); + let seen = hits.load(Ordering::SeqCst); + let _ = socket.writable().await; + let _ = socket.try_write(b"k"); + seen +} + +fn run_child(proxy: &str, signal: &str) -> ExitStatus { + Command::new(std::env::current_exe().expect("the test executable")) + .args([ + "--exact", + "the_menu_list_does_not_use_an_http_proxy", + "--test-threads=1", + "--nocapture", + ]) + .env(ROLE, "menu") + .env(SIGNAL, signal) + .env("HTTP_PROXY", proxy) + .env("ALL_PROXY", proxy) + .env_remove("NO_PROXY") + .env_remove("no_proxy") + .env_remove("REQUEST_METHOD") + .status() + .expect("the proxy child starts") +} + +async fn prove_canary(origin: &str) { + let client = reqwest::Client::builder() + .retry(reqwest::retry::never()) + .build() + .expect("canary client"); + let finished = tokio::time::timeout( + Duration::from_secs(5), + client.get(format!("{origin}/v1/models")).send(), + ) + .await; + assert!( + finished.is_ok(), + "the canary hung instead of finishing through HTTP_PROXY" + ); +} + +fn wait_until_counted() { + let addr = std::env::var(SIGNAL).expect("the parent publishes the canary signal"); + let mut stream = std::net::TcpStream::connect(addr).expect("canary signal"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("read timeout"); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .expect("write timeout"); + stream.write_all(b"c").expect("canary signal"); + let mut buf = [0_u8; 1]; + stream.read_exact(&mut buf).expect("the canary was counted"); +} + +#[tokio::test] +async fn the_menu_list_does_not_follow_a_redirect() { + let hits = Arc::new(AtomicUsize::new(0)); + let recorded = Arc::clone(&hits); + let (dest, dest_listener) = bind().await; + spawn( + dest_listener, + Router::new().route( + "/v1/models", + get(move || { + let recorded = Arc::clone(&recorded); + async move { + recorded.fetch_add(1, Ordering::SeqCst); + StatusCode::OK + } + }), + ), + ); + let (origin, origin_listener) = bind().await; + let location = format!("{dest}/v1/models"); + spawn( + origin_listener, + Router::new().route( + "/v1/models", + get(move |headers: HeaderMap| { + let location = location.clone(); + async move { + assert!( + presented_proof(&headers), + "the direct request arrived without the plan proof" + ); + (StatusCode::FOUND, [("location", location)]) + } + }), + ), + ); + let response = menu(&origin) + .list_models() + .await + .expect("the redirect is the answer"); + assert_eq!(response.status, StatusCode::FOUND); + assert!( + !response + .body + .windows(PROOF.len()) + .any(|window| window == PROOF.as_bytes()) + ); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn the_menu_list_does_not_use_an_http_proxy() { + if std::env::var(ROLE).ok().as_deref() == Some("menu") { + let (base, listener) = bind().await; + spawn( + listener, + Router::new().route( + "/v1/models", + get(move |headers: HeaderMap| async move { + assert!( + presented_proof(&headers), + "the direct request arrived without the plan proof" + ); + "models" + }), + ), + ); + prove_canary(&base).await; + wait_until_counted(); + let response = menu(&base) + .list_models() + .await + .expect("the loopback list completes directly"); + assert!(response.status.is_success()); + return; + } + let (proxy, hits) = proxy_listener().await; + let gate = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gate_addr = gate.local_addr().unwrap().to_string(); + let recorded = Arc::clone(&hits); + let marked = tokio::spawn(canary_hits(gate, recorded)); + let status = tokio::task::spawn_blocking(move || run_child(&proxy, &gate_addr)) + .await + .expect("the child join"); + assert!(status.success(), "the proxy child exited {status}"); + let canary = marked.await.expect("the canary mark"); + assert!( + canary >= 1, + "HTTP_PROXY was not consulted, so the direct-client check proves nothing" + ); + assert_eq!( + hits.load(Ordering::SeqCst), + canary, + "the loopback client contacted the proxy" + ); +} diff --git a/crates/workshop/look/dropdown.css b/crates/workshop/look/dropdown.css index 60b33ec1..2dab73d4 100644 --- a/crates/workshop/look/dropdown.css +++ b/crates/workshop/look/dropdown.css @@ -66,6 +66,22 @@ margin-inline-start: auto; color: var(--cursor-icon-primary); } + .menu-group { + display: flex; + flex-direction: column; + } + .menu-group:not(:first-child) { + margin-top: 2px; + } + .menu-heading { + padding: 2px var(--space-2); + color: var(--cursor-text-tertiary); + font-size: var(--font-size-xs); + font-weight: 400; + line-height: var(--line-height-xs); + cursor: default; + user-select: none; + } /* The select menu (add .menu-select beside .menu): the surface a .select control opens - #181818 with a hairline border and a 6px radius, at @@ -152,6 +168,9 @@ width: 10px; height: 10px; } + .menu-composer .menu-heading { + padding: 2px 6px; + } /* The floating action menu: fixed against the viewport, fading in. */ .menu-popup { diff --git a/crates/workshop/look/dropdown.ts b/crates/workshop/look/dropdown.ts index 414ca40a..625d6b3c 100644 --- a/crates/workshop/look/dropdown.ts +++ b/crates/workshop/look/dropdown.ts @@ -27,6 +27,15 @@ export interface DropdownItem { onClick: () => void; } +/** A titled group of action rows inside a dropdown menu. */ +export interface DropdownGroup { + readonly heading: string; + readonly items: readonly DropdownItem[]; +} + +/** One top-level entry in a dropdown menu: an action row or a titled group. */ +export type DropdownEntry = DropdownItem | DropdownGroup; + /** How one menu opens. Every field is optional; the default is the shared action menu. */ export interface DropdownOptions { /** @@ -63,7 +72,7 @@ export class DropdownMenu { /** Opens a menu of `items` anchored to `trigger`. */ show( trigger: HTMLElement, - items: readonly DropdownItem[], + items: readonly DropdownEntry[], point?: Readonly<{ x: number; y: number }>, options: DropdownOptions = {}, ): void { @@ -89,7 +98,7 @@ export class DropdownMenu { menu.setAttribute("aria-orientation", "vertical"); const buttons: HTMLButtonElement[] = []; - for (const item of items) { + const appendItem = (parent: HTMLElement, item: DropdownItem): void => { const button = document.createElement("button"); button.type = "button"; button.className = @@ -137,7 +146,29 @@ export class DropdownMenu { this.close(); }); buttons.push(button); - menu.appendChild(button); + parent.appendChild(button); + }; + for (const entry of items) { + if ("heading" in entry) { + if (entry.items.length === 0) { + continue; + } + const group = document.createElement("div"); + group.className = "menu-group"; + group.setAttribute("role", "group"); + group.setAttribute("aria-label", entry.heading); + const heading = document.createElement("div"); + heading.className = "menu-heading"; + heading.setAttribute("aria-hidden", "true"); + heading.textContent = entry.heading; + group.appendChild(heading); + for (const item of entry.items) { + appendItem(group, item); + } + menu.appendChild(group); + } else { + appendItem(menu, entry); + } } // The trigger's popup wiring is restored on close, so a trigger that diff --git a/crates/workshop/look/test/workshop-dropdown.mjs b/crates/workshop/look/test/workshop-dropdown.mjs index bdea002d..1becf164 100644 --- a/crates/workshop/look/test/workshop-dropdown.mjs +++ b/crates/workshop/look/test/workshop-dropdown.mjs @@ -265,6 +265,85 @@ dropdown.show(trigger, [ plain.dispose(); } +// --- Headed groups: role=group, inert heading, arrow keys skip heading ------- + +{ + let clicks = 0; + const grouped = new DropdownMenu(); + grouped.show( + trigger, + [ + { label: "Local", onClick: () => (clicks += 1) }, + { + heading: "ChatGPT plan", + items: [ + { label: "gpt-6.1-sol", onClick: () => (clicks += 1) }, + { label: "o4-mini", onClick: () => (clicks += 1) }, + ], + }, + ], + undefined, + { skin: "composer" }, + ); + const menu = [...window.document.querySelectorAll(".menu-popup")].at(-1); + const group = menu?.querySelector(".menu-group"); + const heading = group?.querySelector(".menu-heading"); + const rows = itemsOf(menu); + check( + "an ungrouped row precedes the group in DOM order", + menu?.children[0] === rows[0] && menu?.children[1] === group, + ); + check( + "the group exposes role=group with aria-label", + group?.getAttribute("role") === "group" && group.getAttribute("aria-label") === "ChatGPT plan", + ); + check( + "the heading renders the title, is aria-hidden, and is neither a menuitem nor focusable", + heading?.textContent === "ChatGPT plan" && + heading.getAttribute("aria-hidden") === "true" && + heading.getAttribute("role") === null && + heading.getAttribute("tabindex") === null && + !(heading instanceof window.HTMLButtonElement), + ); + heading?.click(); + check("clicking the heading runs nothing and keeps the menu open", clicks === 0 && menu?.isConnected === true); + + pressKey(menu, "ArrowDown"); + check("ArrowDown starts on the ungrouped first item", window.document.activeElement === rows[0]); + pressKey(menu, "ArrowDown"); + check("ArrowDown skips the heading into the group's first item", window.document.activeElement === rows[1]); + pressKey(menu, "ArrowUp"); + check("ArrowUp skips the heading back to the ungrouped item", window.document.activeElement === rows[0]); + grouped.dispose(); +} + +{ + const firstGroup = new DropdownMenu(); + firstGroup.show(trigger, [ + { + heading: "ChatGPT plan", + items: [ + { label: "gpt-6.1-sol", onClick: () => undefined }, + { label: "o4-mini", onClick: () => undefined }, + ], + }, + ]); + const menu = [...window.document.querySelectorAll(".menu-popup")].at(-1); + const rows = itemsOf(menu); + check( + "when all items belong to a group, the group and its heading stand first in the menu", + menu?.firstElementChild?.classList.contains("menu-group") === true && + menu.firstElementChild.firstElementChild?.classList.contains("menu-heading") === true, + ); + pressKey(menu, "ArrowDown"); + check("ArrowDown from a group-first menu lands on the first menuitem", window.document.activeElement === rows[0]); + pressKey(menu, "Home"); + check("Home skips a leading heading", window.document.activeElement === rows[0]); + pressKey(menu, "End"); + check("End lands on the last menuitem", window.document.activeElement === rows[1]); + firstGroup.dispose(); +} + // --- dispose() closes the open menu ------------------------------------------ { diff --git a/crates/workshop/server-api/src/lib-tests.rs b/crates/workshop/server-api/src/lib-tests.rs index 93c03165..38f76f53 100644 --- a/crates/workshop/server-api/src/lib-tests.rs +++ b/crates/workshop/server-api/src/lib-tests.rs @@ -38,6 +38,17 @@ fn the_desktop_app_facing_surface_names_every_re_export() { short_name::(), "GatewayPublicationError" ); + // The Gateway ChatGPT account seam. + assert_eq!(short_name::(), "ChatgptAccount"); + assert_eq!(short_name::(), "ChatgptAccountError"); + assert_eq!(short_name::(), "ChatgptAccountOp"); + assert_eq!(short_name::(), "ChatgptHttpFailure"); + assert_eq!(short_name::(), "ChatgptJsonFailure"); + assert_eq!(short_name::(), "ChatgptReadTarget"); + assert_eq!( + short_name::(), + "ChatgptSidecarFailure" + ); // The listing of a Plugin directory. list_signature(list_plugin_dir); assert_eq!(short_name::(), "PluginListing"); diff --git a/crates/workshop/server-api/src/lib.rs b/crates/workshop/server-api/src/lib.rs index cbb13cf3..bf228b97 100644 --- a/crates/workshop/server-api/src/lib.rs +++ b/crates/workshop/server-api/src/lib.rs @@ -4,9 +4,9 @@ //! The desktop app (`workshop`) depends on this crate and never on //! `workshop-server`, so server internals do not resolve in the desktop app at //! all. The surface is the configuration types, the in-process server -//! lifecycle, the Gateway publication seam, and the listing of a Plugin -//! directory; the `test-fixtures` feature forwards the server's -//! integration-test seams. +//! lifecycle, the Gateway publication and ChatGPT account seams, and the +//! listing of a Plugin directory; the `test-fixtures` feature forwards the +//! server's integration-test seams. //! //! ## Invariants //! @@ -18,8 +18,10 @@ //! item, and the desktop app's sole view of the server is this crate. pub use workshop_server::{ - AgentsConfig, Config, GatewayConfig, GatewayPublicationError, GatewayUpdater, PluginListing, - ServerConfig, ServerHandle, SpawnError, Termination, discovery, list_plugin_dir, spawn, + AgentsConfig, ChatgptAccount, ChatgptAccountError, ChatgptAccountOp, ChatgptHttpFailure, + ChatgptJsonFailure, ChatgptReadTarget, ChatgptSidecarFailure, Config, GatewayConfig, + GatewayPublicationError, GatewayUpdater, PluginListing, ServerConfig, ServerHandle, SpawnError, + Termination, discovery, list_plugin_dir, spawn, }; /// The server's integration-test seams, forwarded to the desktop app's tests. diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index d50bc9e9..9d1bc890 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -39,6 +39,9 @@ mod bindings; mod broker; + +#[cfg(feature = "test-fixtures")] +pub use broker::{one_plan_round, plan_model_ids}; mod gateway; mod language_guide; mod relay; diff --git a/crates/workshop/server/src/agents/broker-tests.rs b/crates/workshop/server/src/agents/broker-tests.rs index f6a0d333..296ae922 100644 --- a/crates/workshop/server/src/agents/broker-tests.rs +++ b/crates/workshop/server/src/agents/broker-tests.rs @@ -37,8 +37,19 @@ use crate::app::test_helpers::spawn_gateway; /// A registry holding gateway handles bound to `base_url` under `key`, /// with the binding and the guard that keeps the registration alive. fn registry_with_gateway(base_url: &str, key: &str) -> (Registry, GatewayBinding, Registration) { + registry_with_plan_proof(base_url, key, None) +} + +/// `registry_with_gateway`, and the binding carries `plan_proof` when one +/// is given. +fn registry_with_plan_proof( + base_url: &str, + key: &str, + plan_proof: Option<&str>, +) -> (Registry, GatewayBinding, Registration) { let registry = Registry::new(); - let binding = GatewayBinding::new(base_url, key).expect("the binding builds"); + let binding = GatewayBinding::new_with_plan_proof(base_url, key, None, plan_proof) + .expect("the binding builds"); let guard = workshop_gateway::register( ®istry, GatewayHandles::new(binding.clone(), GatewayHealth::new()), @@ -191,6 +202,79 @@ async fn without_a_gateway_registration_rounds_and_model_lists_fail_as_unavailab assert_unavailable(&error); } +#[tokio::test] +async fn a_plan_proof_is_one_header_on_a_round_and_on_the_model_list() { + use std::sync::{Arc, Mutex}; + + const PROOF: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let seen: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let slot = Arc::clone(&seen); + let app = keyed_gateway("first-model", "first-key").layer(axum::middleware::from_fn( + move |headers: HeaderMap, request: axum::extract::Request, next: axum::middleware::Next| { + let slot = Arc::clone(&slot); + async move { + let proofs: Vec = headers + .get_all("x-runvil-plan-proof") + .iter() + .filter_map(|value| value.to_str().ok().map(str::to_owned)) + .collect(); + slot.lock().expect("capture lock").push(proofs); + next.run(request).await + } + }, + )); + let gateway = spawn_gateway(app).await; + let (registry, _, _guard) = registry_with_plan_proof(&gateway, "first-key", Some(PROOF)); + let broker = WorkshopBroker::new(registry); + assert_eq!( + listed(&broker).await.expect("the model list completes"), + ["first-model"] + ); + assert_eq!( + round(&broker).await.expect("the round completes"), + "from first-model" + ); + let captured = seen.lock().expect("capture lock").clone(); + assert_eq!( + captured, + [vec![PROOF.to_owned()], vec![PROOF.to_owned()]], + "the model list and the round each send the proof once" + ); + assert!( + !format!("{captured:?}").contains("owner"), + "only the plan proof is sent" + ); +} + +#[tokio::test] +async fn a_binding_without_a_plan_proof_sends_none() { + use std::sync::{Arc, Mutex}; + + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let slot = Arc::clone(&seen); + let app = keyed_gateway("first-model", "first-key").layer(axum::middleware::from_fn( + move |headers: HeaderMap, request: axum::extract::Request, next: axum::middleware::Next| { + let slot = Arc::clone(&slot); + async move { + slot.lock() + .expect("capture lock") + .push(headers.get_all("x-runvil-plan-proof").iter().count()); + next.run(request).await + } + }, + )); + let gateway = spawn_gateway(app).await; + let (registry, _, _guard) = registry_with_gateway(&gateway, "first-key"); + let broker = WorkshopBroker::new(registry); + listed(&broker).await.expect("the model list completes"); + round(&broker).await.expect("the round completes"); + assert_eq!( + seen.lock().expect("capture lock").as_slice(), + &[0, 0], + "no proof means no header on either call" + ); +} + #[tokio::test] async fn a_streaming_round_hands_its_pieces_to_the_callback_and_answers_whole() { let gateway = spawn_gateway(keyed_gateway("first-model", "first-key")).await; diff --git a/crates/workshop/server/src/agents/broker.rs b/crates/workshop/server/src/agents/broker.rs index 30ec65af..41c09bfb 100644 --- a/crates/workshop/server/src/agents/broker.rs +++ b/crates/workshop/server/src/agents/broker.rs @@ -68,7 +68,10 @@ impl WorkshopBroker { { return Ok(broker.clone()); } - let broker = GatewayBroker::new(gateway.endpoint, gateway.key); + let mut broker = GatewayBroker::new(gateway.endpoint, gateway.key); + if let Some(proof) = gateway.plan_proof { + broker = broker.with_plan_proof(proof); + } *cached = Some((gateway.generation, broker.clone())); Ok(broker) } @@ -199,6 +202,104 @@ impl RunBroker { } } +/// Model ids from one [`WorkshopBroker`] pointed at this Gateway. +/// +/// # Errors +/// Returns an error when the temporary broker registration cannot be created +/// or when fetching the Gateway's model catalog fails. +#[cfg(feature = "test-fixtures")] +pub async fn plan_model_ids( + base_url: &str, + api_key: &str, + plan_proof: Option<&str>, +) -> Result, String> { + let held = HeldBroker::new(base_url, api_key, plan_proof)?; + let catalog = held + .broker + .models() + .await + .map_err(|error| error.to_string())?; + Ok(catalog + .models() + .iter() + .map(|model| model.id().name().to_owned()) + .collect()) +} + +/// One text round on `model` through [`WorkshopBroker`]. +/// +/// # Errors +/// Returns an error when the temporary broker registration cannot be created, +/// when `model` is not a valid gateway model id, or when the completion round +/// fails or returns a non-text result. +#[cfg(feature = "test-fixtures")] +pub async fn one_plan_round( + base_url: &str, + api_key: &str, + plan_proof: Option<&str>, + model: &str, + prompt: &str, +) -> Result { + use std::num::NonZeroU32; + + use runvil::ids::RoundId; + use runvil::model::{CompletionResult, ModelId, ModelInvocation}; + + let held = HeldBroker::new(base_url, api_key, plan_proof)?; + let model_id = ModelId::gateway(model).map_err(|_| "the model name is not valid".to_owned())?; + let Some(window) = NonZeroU32::new(4096) else { + return Err("the round's context window is zero".to_owned()); + }; + let completion = held + .broker + .chat( + ModelBinding::new( + "writer", + "the round's model", + model_id, + ModelInvocation::new(), + window, + ), + vec![Message::user(prompt)], + Vec::new(), + CompletionOptions::new(model), + Round::new(RoundId::new(1), ReplyOrigin::Infer), + ) + .await + .map_err(|error| error.to_string())?; + match completion.result() { + CompletionResult::Text(text) => Ok(text.clone()), + other => Err(format!("the round did not reply with text: {other:?}")), + } +} + +/// A broker whose gateway registration lives as long as this value. +#[cfg(feature = "test-fixtures")] +struct HeldBroker { + broker: WorkshopBroker, + _guard: workshop_registry::Registration, +} + +#[cfg(feature = "test-fixtures")] +impl HeldBroker { + fn new(base_url: &str, api_key: &str, plan_proof: Option<&str>) -> Result { + use workshop_gateway::{GatewayBinding, GatewayHandles, GatewayHealth}; + use workshop_registry::Registry; + + let registry = Registry::new(); + let binding = GatewayBinding::new_with_plan_proof(base_url, api_key, None, plan_proof) + .map_err(|error| error.to_string())?; + let guard = workshop_gateway::register( + ®istry, + GatewayHandles::new(binding, GatewayHealth::new()), + ); + Ok(Self { + broker: WorkshopBroker::new(registry), + _guard: guard, + }) + } +} + impl InferenceBroker for RunBroker { fn models(&self) -> BoxFuture<'static, Result> { self.inner.models() diff --git a/crates/workshop/server/src/agents/gateway.rs b/crates/workshop/server/src/agents/gateway.rs index 8543d092..a13f358f 100644 --- a/crates/workshop/server/src/agents/gateway.rs +++ b/crates/workshop/server/src/agents/gateway.rs @@ -17,6 +17,9 @@ pub(super) struct UsableGateway { pub(super) endpoint: GatewayEndpoint, /// The bearer paired with the base URL. pub(super) key: SecretString, + /// The plan proof paired with this generation, when the private file + /// named its boot. Not a bearer. + pub(super) plan_proof: Option, } impl UsableGateway { @@ -29,6 +32,9 @@ impl UsableGateway { generation: snapshot.generation(), endpoint: GatewayEndpoint::new(&api_root).ok()?, key: SecretString::new(snapshot.api_key()).ok()?, + plan_proof: snapshot + .plan_proof() + .and_then(|proof| SecretString::new(proof).ok()), }) } } diff --git a/crates/workshop/server/src/agents/relay-tests.rs b/crates/workshop/server/src/agents/relay-tests.rs index 2c9f7573..ca98d043 100644 --- a/crates/workshop/server/src/agents/relay-tests.rs +++ b/crates/workshop/server/src/agents/relay-tests.rs @@ -135,3 +135,19 @@ async fn a_gateway_known_down_short_circuits_the_catalog_with_bad_gateway() { "the short-circuit message is user-visible" ); } + +#[test] +fn a_chatgpt_sign_in_code_is_named_in_the_failure_label() { + let body = br#"{"error":{"code":"chatgpt_sign_in_required","message":"sign in"}}"#; + assert_eq!( + sign_in_failure_label("403 Forbidden", body), + "Gateway error: 403 Forbidden (chatgpt_sign_in_required)" + ); + assert_eq!( + sign_in_failure_label( + "500 Internal Server Error", + br#"{"error":{"code":"upstream_unavailable"}}"# + ), + "Gateway error: 500 Internal Server Error" + ); +} diff --git a/crates/workshop/server/src/agents/relay.rs b/crates/workshop/server/src/agents/relay.rs index 77a24450..559da648 100644 --- a/crates/workshop/server/src/agents/relay.rs +++ b/crates/workshop/server/src/agents/relay.rs @@ -46,7 +46,7 @@ fn report_gateway_outcome( match result { Ok(upstream) if upstream.status.is_success() => push.push_idle(), Ok(upstream) => push.push_failure( - format!("Gateway error: {}", upstream.status), + sign_in_failure_label(&upstream.status.to_string(), &upstream.body), format!("{route} answered a non-success status"), Activity::General, ), @@ -70,6 +70,25 @@ fn relay(result: Result) -> Response { } } +/// The status-bar label for a non-success Gateway response. A ChatGPT +/// sign-in code in the body is appended so the page can refresh the +/// account; any other body keeps the status text alone. +pub(crate) fn sign_in_failure_label(status: &str, body: &[u8]) -> String { + let label = format!("Gateway error: {status}"); + let Ok(value) = serde_json::from_slice::(body) else { + return label; + }; + let Some(code) = value + .get("error") + .and_then(|error| error.get("code")) + .and_then(serde_json::Value::as_str) + .filter(|code| code.starts_with("chatgpt_")) + else { + return label; + }; + format!("{label} ({code})") +} + /// The wire message of a gateway transport failure: the error's own /// summary line, with the source chain appended in debug builds only. fn gateway_message(error: &GatewayError) -> String { diff --git a/crates/workshop/server/src/app.rs b/crates/workshop/server/src/app.rs index afe7a0a0..69dfb44c 100644 --- a/crates/workshop/server/src/app.rs +++ b/crates/workshop/server/src/app.rs @@ -16,7 +16,7 @@ pub(crate) mod test_helpers; mod tests; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use axum::Router; @@ -46,6 +46,7 @@ use crate::routes; pub struct AppState { backoff: ReconnectBackoff, registry: Registry, + config_origin: Arc>, // Keeps the subsystems' self-registrations alive; dropping the last // state clone deregisters them. _registrations: Registrations, @@ -217,6 +218,22 @@ impl AppState { self.required::() } + /// Returns the separate config listener's loopback origin once bound. + pub(crate) fn config_origin(&self) -> Option<&str> { + self.config_origin.get().map(String::as_str) + } + + /// Publishes the separate config listener's bound loopback origin. + pub(crate) fn set_config_origin(&self, origin: String) { + let _ = self.config_origin.set(origin); + } + + /// Returns the main-origin `Content-Security-Policy` header value, binding + /// `frame-src` exclusively to the separate config origin once bound. + pub(crate) fn main_csp(&self) -> axum::http::HeaderValue { + crate::csp::main_policy_for(self.config_origin()) + } + /// Reopens the workspace file that was open when the server last /// ran, and returns whether one was reopened. Boot awaits this after /// binding the listener and before it serves, so the first @@ -358,6 +375,7 @@ pub enum StateError { /// none on the WebSocket upgrades. pub fn router(state: AppState) -> Router { let registry = state.registry().clone(); + let csp_state = state.clone(); let mut api = Router::new() .merge(routes::realtime::routes(state.clone())) .merge(routes::gateway_config::routes(state)) @@ -376,5 +394,15 @@ pub fn router(state: AppState) -> Router { // is stamped with the CSP, error envelopes included, so the desktop app's // External-origin webview runs under the policy no matter which // route answered. - .layer(axum::middleware::from_fn(crate::csp::header)) + .layer(axum::middleware::from_fn_with_state( + csp_state, + crate::csp::header, + )) +} + +/// Returns the router for the dedicated config listener: serves only the +/// proxied Gateway config documents, guarded to loopback `Host` + `GET`/`HEAD` +/// and framed exclusively by `parent_origin`. +pub(crate) fn config_router(state: AppState, parent_origin: &str) -> Router { + routes::gateway_config::config_router(state, parent_origin) } diff --git a/crates/workshop/server/src/app/compose.rs b/crates/workshop/server/src/app/compose.rs index 9a84f70e..04975c93 100644 --- a/crates/workshop/server/src/app/compose.rs +++ b/crates/workshop/server/src/app/compose.rs @@ -3,7 +3,7 @@ //! subsystem owns a `register` helper here so the composition reads as a //! single list of subsystem registrations in registration order. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use workshop_gateway::{GatewayBinding, GatewayHandles, GatewayHealth, ResolvedGateway}; use workshop_menu::MenuHandles; @@ -66,6 +66,7 @@ pub(super) fn compose( Ok(AppState { backoff, registry, + config_origin: Arc::new(OnceLock::new()), _registrations: registrations, }) } @@ -124,10 +125,11 @@ fn register_gateway( if let Some(identity) = gateway.identity() { workshop_gateway::report_version(identity.version(), push); } - let gateway_binding = GatewayBinding::new_with_identity( + let gateway_binding = GatewayBinding::new_with_plan_proof( gateway.base_url(), gateway.api_key(), gateway.identity().cloned(), + gateway.plan_proof(), ) .map_err(StateError::Gateway)?; let backoff = ReconnectBackoff::new(); diff --git a/crates/workshop/server/src/cross_site.rs b/crates/workshop/server/src/cross_site.rs index 84064305..c2e5b4c8 100644 --- a/crates/workshop/server/src/cross_site.rs +++ b/crates/workshop/server/src/cross_site.rs @@ -54,6 +54,18 @@ pub(crate) async fn guard(request: Request, next: Next) -> Response { next.run(request).await } +/// Guard for the separate config listener: allows only `GET`/`HEAD` from +/// a loopback `Host` (refusing DNS rebinding and state-changing methods). +pub(crate) async fn config_guard(request: Request, next: Next) -> Response { + if !matches!(*request.method(), Method::GET | Method::HEAD) { + return axum::http::StatusCode::METHOD_NOT_ALLOWED.into_response(); + } + if !host_is_loopback(&request) { + return AppError::CrossSite.into_response(); + } + next.run(request).await +} + /// Whether the request's authority is loopback, closing DNS rebinding: a /// page on a rebound hostname reaches this server with same-origin /// Sec-Fetch metadata and a freely chosen content type, but its `Host` @@ -72,7 +84,7 @@ fn host_is_loopback(request: &Request) -> bool { None => return true, }, }; - is_loopback_origin(&format!("http://{authority}")) + workshop_support::is_loopback_http_url(&format!("http://{authority}")) } /// Whether the request declares an `application/json` body, ignoring @@ -92,24 +104,9 @@ pub(crate) fn origin_allowed(headers: &HeaderMap) -> bool { let Some(origin) = headers.get(header::ORIGIN) else { return true; }; - origin.to_str().is_ok_and(is_loopback_origin) -} - -/// Whether `origin` parses as an http(s) URL whose host is loopback. The -/// opaque `null` origin (sandboxed iframes) does not parse and is refused. -fn is_loopback_origin(origin: &str) -> bool { - let Ok(url) = url::Url::parse(origin) else { - return false; - }; - if !matches!(url.scheme(), "http" | "https") { - return false; - } - match url.host() { - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), - None => false, - } + origin + .to_str() + .is_ok_and(workshop_support::is_loopback_http_url) } #[cfg(test)] @@ -135,7 +132,9 @@ mod tests { "http://[::1]:7910", "https://127.0.0.1", ] { - assert!(is_loopback_origin(origin), "{origin} must be allowed"); + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, origin.parse().expect("valid header")); + assert!(origin_allowed(&headers), "{origin} must be allowed"); } } @@ -149,7 +148,9 @@ mod tests { "null", "", ] { - assert!(!is_loopback_origin(origin), "{origin} must be refused"); + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, origin.parse().expect("valid header")); + assert!(!origin_allowed(&headers), "{origin} must be refused"); } } diff --git a/crates/workshop/server/src/csp.rs b/crates/workshop/server/src/csp.rs index 249df2c2..b54d62c6 100644 --- a/crates/workshop/server/src/csp.rs +++ b/crates/workshop/server/src/csp.rs @@ -16,7 +16,7 @@ use axum::http::{HeaderValue, header}; use axum::middleware::Next; use axum::response::Response; -/// The policy value, one header for every response. +/// The policy value prefix for every main-origin response (before `frame-src`). /// /// `img-src` keeps `https:` so model-written markdown can still render /// remote images. `style-src` keeps `'unsafe-inline'` because the UI's @@ -24,36 +24,74 @@ use axum::response::Response; /// mounts its modules as `