From cec080aa982ede2797de0c7b10c5adae4d7cd6a2 Mon Sep 17 00:00:00 2001 From: Michel Paulissen <862400+MichelPaulissen@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:37:17 +0200 Subject: [PATCH] Update public browser login session flow --- DISASMER_PUBLIC_TREE.json | 4 +- crates/disasmer-cli/src/main.rs | 332 ++++++++++++++++++++++-- scripts/cli-browser-login-flow-smoke.js | 61 ++++- scripts/cli-first-contract-smoke.js | 20 ++ 4 files changed, 388 insertions(+), 29 deletions(-) diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index 1340e71..eadeef7 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "09993cb75187f1e8798d6429946085e167059e9f", - "release_name": "dryrun-09993cb75187", + "source_commit": "f3285b63dd9e8a320e8ca1d1dd188aa18fa9a59c", + "release_name": "dryrun-f3285b63dd9e", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 4ffcf63..8a95d8b 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -617,6 +617,8 @@ struct LoginCompletionBoundaryEvidence { cli_contacted_coordinator: bool, coordinator_address: String, scoped_cli_session_received: bool, + local_cli_session_file_written: bool, + provider_tokens_persisted_locally: bool, provider_tokens_exposed_to_cli: bool, provider_tokens_sent_to_nodes: bool, coordinator_session_requests: u64, @@ -724,6 +726,21 @@ struct ProjectConfig { coordinator: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct StoredCliSession { + kind: String, + coordinator: String, + tenant: String, + project: String, + user: String, + cli_session_credential_kind: String, + token_expiry_posture: String, + expires_at: Option, + provider_tokens_exposed_to_cli: bool, + provider_tokens_sent_to_nodes: bool, + created_at_unix_seconds: u64, +} + fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { let config = read_project_config(&cwd)?; let coordinator = args.scope.coordinator.or_else(|| { @@ -737,7 +754,7 @@ fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result { "cwd": cwd, "coordinator": coordinator, "coordinator_reachability": coordinator_reachability, - "auth": auth_state_value(), + "auth": auth_state_value(&cwd)?, "project": config, "dependencies": { "cargo": command_available("cargo"), @@ -826,13 +843,61 @@ fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result { fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { let config = read_project_config(&cwd)?; + let stored_session = read_cli_session(&cwd)?; + let active_coordinator = args + .scope + .coordinator + .clone() + .or_else(|| { + config + .as_ref() + .and_then(|config| config.coordinator.clone()) + }) + .or_else(|| { + stored_session + .as_ref() + .map(|session| session.coordinator.clone()) + }) + .unwrap_or_else(default_operator_endpoint); + let tenant = effective_scope_value( + &args.scope.tenant, + config + .as_ref() + .map(|config| config.tenant.as_str()) + .or_else(|| { + stored_session + .as_ref() + .map(|session| session.tenant.as_str()) + }), + "tenant", + ); + let project = effective_scope_value( + &args.scope.project, + config + .as_ref() + .map(|config| config.project.as_str()) + .or_else(|| { + stored_session + .as_ref() + .map(|session| session.project.as_str()) + }), + "project", + ); + let principal = effective_scope_value( + &args.scope.user, + config + .as_ref() + .map(|config| config.user.as_str()) + .or_else(|| stored_session.as_ref().map(|session| session.user.as_str())), + "user", + ); Ok(json!({ "command": "auth status", - "active_coordinator": args.scope.coordinator.or_else(|| config.as_ref().and_then(|config| config.coordinator.clone())).unwrap_or_else(default_operator_endpoint), - "principal": args.scope.user, - "tenant": args.scope.tenant, - "project": args.scope.project, - "session": auth_state_value(), + "active_coordinator": active_coordinator, + "principal": principal, + "tenant": tenant, + "project": project, + "session": auth_state_value(&cwd)?, "project_config": config, })) } @@ -2739,7 +2804,10 @@ fn run_cli() -> Result<()> { } Commands::Run(args) => { let json_output = args.json; - let report = run_report(args, std::env::current_dir()?, session_from_env())?; + let cwd = std::env::current_dir()?; + let session_project = args.project.clone().unwrap_or_else(|| cwd.clone()); + let session = session_from_sources(&session_project)?; + let report = run_report(args, cwd, session)?; emit_report(&report, json_output)?; } Commands::Node { @@ -2912,6 +2980,29 @@ fn write_project_config(project: &Path, config: &ProjectConfig) -> Result<()> { .with_context(|| format!("failed to write {}", file.display())) } +fn read_cli_session(project: &Path) -> Result> { + let file = session_config_file(project); + if !file.exists() { + return Ok(None); + } + let bytes = + std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?; + let session = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse {}", file.display()))?; + Ok(Some(session)) +} + +fn write_cli_session(project: &Path, session: &StoredCliSession) -> Result { + let file = session_config_file(project); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + std::fs::write(&file, serde_json::to_vec_pretty(session)?) + .with_context(|| format!("failed to write {}", file.display()))?; + Ok(file) +} + fn effective_project_scope(scope: &CliScopeArgs, config: Option<&ProjectConfig>) -> CliScopeArgs { CliScopeArgs { coordinator: scope @@ -2949,30 +3040,48 @@ fn effective_scope_value( } } -fn auth_state_value() -> Value { +fn auth_state_value(cwd: &Path) -> Result { match session_from_env() { - CliSession::Anonymous => json!({ - "kind": "anonymous", - "authenticated": false, - "source": "environment", - "token_expiry_posture": "no_session", - }), + CliSession::Anonymous => { + if let Some(session) = read_cli_session(cwd)? { + return Ok(json!({ + "kind": session.kind, + "authenticated": true, + "source": "session_file", + "coordinator": session.coordinator, + "tenant": session.tenant, + "project": session.project, + "principal": session.user, + "cli_session_credential_kind": session.cli_session_credential_kind, + "provider_tokens_exposed_to_cli": session.provider_tokens_exposed_to_cli, + "provider_tokens_exposed_to_nodes": session.provider_tokens_sent_to_nodes, + "expires_at": session.expires_at, + "token_expiry_posture": session.token_expiry_posture, + })); + } + Ok(json!({ + "kind": "anonymous", + "authenticated": false, + "source": "environment", + "token_expiry_posture": "no_session", + })) + } CliSession::HumanSession => { let expires_at = std::env::var("DISASMER_TOKEN_EXPIRES_AT").ok(); - json!({ + Ok(json!({ "kind": "human", "authenticated": true, "source": "DISASMER_TOKEN", "provider_tokens_exposed_to_nodes": false, "expires_at": expires_at, "token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" }, - }) + })) } CliSession::AgentPublicKey { agent, public_key_fingerprint, browser_interaction_required, - } => json!({ + } => Ok(json!({ "kind": "agent_public_key", "authenticated": true, "agent": agent, @@ -2980,7 +3089,7 @@ fn auth_state_value() -> Value { "public_key_fingerprint": public_key_fingerprint, "browser_interaction_required": browser_interaction_required, "token_expiry_posture": "not_applicable_public_key", - }), + })), } } @@ -3682,6 +3791,20 @@ fn execute_browser_login_completion_for_plan( .and_then(Value::as_bool) .unwrap_or(true); let provider_tokens_exposed_to_cli = contains_provider_token_field(&coordinator_response); + let local_cli_session_file_written = if scoped_cli_session_received { + let cwd = std::env::current_dir()?; + let stored_session = stored_cli_session_from_login_response( + &args, + &coordinator, + &coordinator_response, + provider_tokens_exposed_to_cli, + provider_tokens_sent_to_nodes, + ); + write_cli_session(&cwd, &stored_session)?; + true + } else { + false + }; Ok(LoginCompletionReport { plan, @@ -3689,6 +3812,8 @@ fn execute_browser_login_completion_for_plan( cli_contacted_coordinator: true, coordinator_address: coordinator, scoped_cli_session_received, + local_cli_session_file_written, + provider_tokens_persisted_locally: false, provider_tokens_exposed_to_cli, provider_tokens_sent_to_nodes, coordinator_session_requests: session.requests(), @@ -3697,6 +3822,54 @@ fn execute_browser_login_completion_for_plan( }) } +fn stored_cli_session_from_login_response( + args: &LoginArgs, + coordinator: &str, + coordinator_response: &Value, + provider_tokens_exposed_to_cli: bool, + provider_tokens_sent_to_nodes: bool, +) -> StoredCliSession { + let session = coordinator_response.get("session").unwrap_or(&Value::Null); + let expires_at = session + .get("expires_at") + .or_else(|| session.get("token_expires_at")) + .and_then(Value::as_str) + .map(str::to_owned); + StoredCliSession { + kind: "human".to_owned(), + coordinator: coordinator.to_owned(), + tenant: session + .get("tenant") + .and_then(Value::as_str) + .unwrap_or(&args.tenant) + .to_owned(), + project: session + .get("project") + .and_then(Value::as_str) + .unwrap_or(&args.project) + .to_owned(), + user: session + .get("user") + .and_then(Value::as_str) + .unwrap_or(&args.user) + .to_owned(), + cli_session_credential_kind: session + .get("cli_session_credential_kind") + .and_then(Value::as_str) + .unwrap_or("CliDeviceSession") + .to_owned(), + token_expiry_posture: if expires_at.is_some() { + "expires_at".to_owned() + } else { + "unknown_coordinator_session".to_owned() + }, + expires_at, + provider_tokens_exposed_to_cli, + provider_tokens_sent_to_nodes, + created_at_unix_seconds: unix_timestamp_seconds(), + } +} + fn execute_interactive_browser_login(mut args: LoginArgs) -> Result { args.browser = true; let callback_path = browser_callback_url(); @@ -3868,6 +4041,13 @@ fn browser_callback_timeout() -> Duration { Duration::from_secs(seconds) } +fn unix_timestamp_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + fn read_browser_callback_request(stream: &TcpStream) -> Result { let mut reader = BufReader::new( stream @@ -4724,6 +4904,17 @@ fn session_from_env() -> CliSession { CliSession::Anonymous } +fn session_from_sources(project: &Path) -> Result { + let session = session_from_env(); + if session.is_authenticated() { + return Ok(session); + } + if read_cli_session(project)?.is_some() { + return Ok(CliSession::HumanSession); + } + Ok(CliSession::Anonymous) +} + fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback_user: &str) { let Value::Object(map) = request else { return; @@ -5586,6 +5777,60 @@ mod tests { }))); } + #[test] + fn stored_browser_login_session_omits_provider_token_values() { + let Cli { + command: Commands::Login(args), + } = parse(&[ + "disasmer", + "login", + "--browser", + "--coordinator", + "https://coord.example.test", + "--tenant", + "tenant-cli", + "--project-id", + "project-cli", + "--user", + "user-cli", + ]) + else { + panic!("wrong command"); + }; + let stored = stored_cli_session_from_login_response( + &args, + "https://coord.example.test", + &json!({ + "session": { + "tenant": "tenant-live", + "project": "project-live", + "user": "user-live", + "cli_session_credential_kind": "CliDeviceSession", + "expires_at": "2026-07-04T00:00:00Z", + "access_token": "provider-secret", + "id_token": "provider-id-token", + "provider_tokens_sent_to_nodes": false + } + }), + true, + false, + ); + let serialized = serde_json::to_string(&stored).unwrap(); + + assert_eq!(stored.kind, "human"); + assert_eq!(stored.coordinator, "https://coord.example.test"); + assert_eq!(stored.tenant, "tenant-live"); + assert_eq!(stored.project, "project-live"); + assert_eq!(stored.user, "user-live"); + assert_eq!(stored.token_expiry_posture, "expires_at"); + assert!(stored.provider_tokens_exposed_to_cli); + assert!(!stored.provider_tokens_sent_to_nodes); + assert!(!serialized.contains("provider-secret")); + assert!(!serialized.contains("provider-id-token")); + assert!(!serialized.contains("access_token")); + assert!(!serialized.contains("id_token")); + } + #[test] fn agent_enroll_uses_public_key_without_browser_each_run() { let Cli { @@ -5977,6 +6222,57 @@ mod tests { assert_eq!(report["coordinator_reachability"]["response"]["epoch"], 42); } + #[test] + fn auth_status_reads_stored_cli_session_without_provider_tokens() { + let temp = tempfile::tempdir().unwrap(); + write_cli_session( + temp.path(), + &StoredCliSession { + kind: "human".to_owned(), + coordinator: "https://coord.example.test".to_owned(), + tenant: "tenant-session".to_owned(), + project: "project-session".to_owned(), + user: "user-session".to_owned(), + cli_session_credential_kind: "CliDeviceSession".to_owned(), + token_expiry_posture: "expires_at".to_owned(), + expires_at: Some("2026-07-04T00:00:00Z".to_owned()), + provider_tokens_exposed_to_cli: false, + provider_tokens_sent_to_nodes: false, + created_at_unix_seconds: 1, + }, + ) + .unwrap(); + + let report = auth_status_report( + AuthStatusArgs { + scope: CliScopeArgs { + coordinator: None, + tenant: "tenant".to_owned(), + project: "project".to_owned(), + user: "user".to_owned(), + json: false, + }, + }, + temp.path().to_path_buf(), + ) + .unwrap(); + + assert_eq!(report["active_coordinator"], "https://coord.example.test"); + assert_eq!(report["principal"], "user-session"); + assert_eq!(report["tenant"], "tenant-session"); + assert_eq!(report["project"], "project-session"); + assert_eq!(report["session"]["kind"], "human"); + assert_eq!(report["session"]["source"], "session_file"); + assert_eq!(report["session"]["authenticated"], true); + assert_eq!( + report["session"]["cli_session_credential_kind"], + "CliDeviceSession" + ); + assert_eq!(report["session"]["token_expiry_posture"], "expires_at"); + assert_eq!(report["session"]["provider_tokens_exposed_to_cli"], false); + assert_eq!(report["session"]["provider_tokens_exposed_to_nodes"], false); + } + #[test] fn cli_first_mvp_command_surface_parses() { for args in [ diff --git a/scripts/cli-browser-login-flow-smoke.js b/scripts/cli-browser-login-flow-smoke.js index 88f1e5c..427efbd 100644 --- a/scripts/cli-browser-login-flow-smoke.js +++ b/scripts/cli-browser-login-flow-smoke.js @@ -9,7 +9,10 @@ const path = require("path"); const repo = path.resolve(__dirname, ".."); const tmp = path.join(repo, "target", "acceptance", "tmp", "cli-browser-login-flow"); +const project = path.join(tmp, "project"); +fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true }); +fs.mkdirSync(project, { recursive: true }); function writeOpener() { const opener = path.join(tmp, "browser-opener.js"); @@ -86,6 +89,7 @@ async function startCoordinator() { user: request.user, browser_credential_kind: "BrowserSession", cli_session_credential_kind: "CliDeviceSession", + expires_at: "2026-07-04T00:00:00Z", provider_tokens_sent_to_nodes: false, flow: { authorization_url: "http://127.0.0.1:1/application/o/authorize/", @@ -115,8 +119,19 @@ async function startCoordinator() { function runDisasmer(args, env) { return new Promise((resolve, reject) => { - const child = cp.spawn("cargo", args, { - cwd: repo, + const child = cp.spawn("cargo", [ + "run", + "-q", + "--manifest-path", + path.join(repo, "Cargo.toml"), + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + ...args, + ], { + cwd: project, env, stdio: ["ignore", "pipe", "pipe"], }); @@ -157,13 +172,6 @@ function runDisasmer(args, env) { try { const stdout = await runDisasmer( [ - "run", - "-q", - "-p", - "disasmer-cli", - "--bin", - "disasmer", - "--", "login", "--browser", "--json", @@ -190,10 +198,45 @@ function runDisasmer(args, env) { assert.strictEqual(report.plan.coordinator, coordinator.url); assert.strictEqual(report.boundary.cli_contacted_coordinator, true); assert.strictEqual(report.boundary.scoped_cli_session_received, true); + assert.strictEqual(report.boundary.local_cli_session_file_written, true); + assert.strictEqual(report.boundary.provider_tokens_persisted_locally, false); assert.strictEqual(report.boundary.provider_tokens_exposed_to_cli, false); assert.strictEqual(report.boundary.provider_tokens_sent_to_nodes, false); assert.strictEqual(report.boundary.coordinator_session_requests, 1); assert.strictEqual(coordinator.requests.length, 1); + + const sessionFile = path.join(project, ".disasmer", "session.json"); + assert(fs.existsSync(sessionFile), "browser login should write a local CLI session file"); + const sessionText = fs.readFileSync(sessionFile, "utf8"); + const session = JSON.parse(sessionText); + assert.strictEqual(session.kind, "human"); + assert.strictEqual(session.coordinator, coordinator.url); + assert.strictEqual(session.tenant, "tenant-smoke"); + assert.strictEqual(session.project, "project-smoke"); + assert.strictEqual(session.user, "user-smoke"); + assert.strictEqual(session.cli_session_credential_kind, "CliDeviceSession"); + assert.strictEqual(session.token_expiry_posture, "expires_at"); + assert.strictEqual(session.expires_at, "2026-07-04T00:00:00Z"); + assert.strictEqual(session.provider_tokens_exposed_to_cli, false); + assert.strictEqual(session.provider_tokens_sent_to_nodes, false); + assert.doesNotMatch( + sessionText, + /access_token|refresh_token|id_token|provider-secret|browser-smoke-code|Bearer/ + ); + + const authStatus = JSON.parse( + await runDisasmer(["auth", "status", "--json"], { + ...process.env, + }) + ); + assert.strictEqual(authStatus.active_coordinator, coordinator.url); + assert.strictEqual(authStatus.principal, "user-smoke"); + assert.strictEqual(authStatus.tenant, "tenant-smoke"); + assert.strictEqual(authStatus.project, "project-smoke"); + assert.strictEqual(authStatus.session.kind, "human"); + assert.strictEqual(authStatus.session.source, "session_file"); + assert.strictEqual(authStatus.session.provider_tokens_exposed_to_cli, false); + assert.strictEqual(authStatus.session.provider_tokens_exposed_to_nodes, false); } finally { if (coordinator.requests.length === 0) { await coordinator.close().catch(() => {}); diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 86446b9..0cf53dd 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -36,6 +36,7 @@ const coordinator = read("crates/disasmer-coordinator/src/service.rs"); const cliFirstAcceptance = read("scripts/acceptance-cli-first.sh"); const outputModeSmoke = read("scripts/cli-output-mode-smoke.js"); const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); +const browserLoginFlowSmoke = read("scripts/cli-browser-login-flow-smoke.js"); expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); expect( @@ -143,6 +144,12 @@ for (const [name, pattern] of [ ["top-level logout command", /enum Commands[\s\S]*Logout\(AuthLogoutArgs\)[\s\S]*Auth \{/], ["login non-interactive flag", /struct LoginArgs[\s\S]*non_interactive: bool/], ["run non-interactive flag", /struct RunArgs[\s\S]*non_interactive: bool/], + ["stored CLI session model", /struct StoredCliSession[\s\S]*cli_session_credential_kind[\s\S]*provider_tokens_exposed_to_cli[\s\S]*provider_tokens_sent_to_nodes/], + ["read CLI session helper", /fn read_cli_session\(project: &Path\) -> Result>/], + ["write CLI session helper", /fn write_cli_session\(project: &Path, session: &StoredCliSession\) -> Result/], + ["session source fallback", /fn session_from_sources\(project: &Path\) -> Result[\s\S]*read_cli_session\(project\)\?\.is_some\(\)/], + ["browser login writes local CLI session", /local_cli_session_file_written[\s\S]*write_cli_session\(&cwd, &stored_session\)\?/], + ["browser login does not persist provider tokens", /provider_tokens_persisted_locally:\s*false/], ["non-interactive auth report", /fn non_interactive_auth_machine_error[\s\S]*browser_opened[\s\S]*false/], ["human report renderer", /fn human_report\(value: &Value\) -> String/], ["shared report emitter", /fn emit_report\(report: &T, json_output: bool\) -> Result<\(\)>/], @@ -171,6 +178,8 @@ for (const [name, pattern] of [ ["CLI primary workflow help coverage", /fn top_level_help_exposes_primary_workflow_without_auth\(\)/], ["CLI non-interactive run auth coverage", /fn non_interactive_run_without_session_requires_explicit_auth_or_local\(\)/], ["CLI non-interactive browser login coverage", /fn browser_login_non_interactive_fails_before_opening_browser\(\)/], + ["CLI stored browser session coverage", /fn stored_browser_login_session_omits_provider_token_values\(\)/], + ["CLI auth status session-file coverage", /fn auth_status_reads_stored_cli_session_without_provider_tokens\(\)/], ["CLI version coverage", /fn top_level_version_is_available\(\)/], ["CLI JSON parse coverage", /fn cli_first_json_mode_parses_for_primary_commands\(\)/], ["CLI human output coverage", /fn human_report_is_text_not_json\(\)/], @@ -204,6 +213,17 @@ for (const [name, pattern] of [ expect(cli, name, pattern); } +expect( + browserLoginFlowSmoke, + "browser login smoke checks session file", + /provider_tokens_persisted_locally[\s\S]*path\.join\(project, "\.disasmer", "session\.json"\)[\s\S]*\["auth", "status", "--json"\]/ +); +expect( + browserLoginFlowSmoke, + "browser login smoke rejects provider token persistence", + /assert\.doesNotMatch\([\s\S]*access_token\|refresh_token\|id_token/ +); + expect( coordinator, "coordinator whole-process cancellation coverage",