From 9ff23b632fb1a6e57f35d550a4736fe40665a6a7 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 01:07:58 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(traces):=20ironclaw-reborn=20traces=20?= =?UTF-8?q?enroll-instance=20=E2=80=94=20admin=20instance=20enrollment=20C?= =?UTF-8?q?LI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Path B gap from PR #5280: onboard_instance_with_sink existed but had no product entry point, so invite-based (DeviceKey, per-user-attributed) instance enrollment was unreachable for admins. - onboarding::onboard_instance_at_base — base-dir-parameterised instance enrollment with the default direct sink; targets the scope-None location so every user without a personal enrollment inherits it via resolve_trace_credentials. Test pins the instance-dir targeting (policy + promoted device key at trace_contributions/, no users/ dir). - New CLI subcommand: traces enroll-instance --invite [--include-message-text] [--include-tool-payloads] [--json]. Host-shell possession is the admin gate, matching traces opt-in's trust boundary for the global policy. Parse tests cover flags and required --invite. - slice1 plan note updated: the deferred admin surface now exists. Also in this commit (same file): mint_account_login_link direct variant and DirectPinnedContributionSink used by the WebUI surface in the next commit. Co-Authored-By: Claude Fable 5 --- .../src/commands/traces/contributor.rs | 6 + .../src/commands/traces/mod.rs | 87 +++++++++ .../src/commands/traces/tests.rs | 37 ++++ .../src/contribution.rs | 179 ++++++++++++++++-- .../src/onboarding/mod.rs | 21 +- .../src/onboarding/tests.rs | 50 +++++ .../components/trace-commons-tab.test.mjs | 87 --------- ...ons-slice1-resolver-instance-enrollment.md | 10 +- 8 files changed, 375 insertions(+), 102 deletions(-) delete mode 100644 crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.mjs diff --git a/crates/ironclaw_reborn_cli/src/commands/traces/contributor.rs b/crates/ironclaw_reborn_cli/src/commands/traces/contributor.rs index d9c4fbf2d2f..229d96db172 100644 --- a/crates/ironclaw_reborn_cli/src/commands/traces/contributor.rs +++ b/crates/ironclaw_reborn_cli/src/commands/traces/contributor.rs @@ -42,6 +42,12 @@ pub(super) async fn dispatch(cmd: TracesSubcommand) -> anyhow::Result<()> { min_submission_score, }), TracesSubcommand::OptOut { user_scope } => opt_out(user_scope.as_deref()), + TracesSubcommand::EnrollInstance { + invite, + include_message_text, + include_tool_payloads, + json, + } => enroll_instance(&invite, include_message_text, include_tool_payloads, json).await, TracesSubcommand::Status { json, user_scope } => { show_policy_status(json, user_scope.as_deref()) } diff --git a/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs b/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs index cca8a5c31fd..a7f1718069e 100644 --- a/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs +++ b/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs @@ -129,6 +129,31 @@ enum TracesSubcommand { user_scope: Option, }, + /// Enroll this ENTIRE INSTANCE in Trace Commons with an operator invite + /// link (admin operation — requires shell access to the instance host). + /// Every user without a personal enrollment inherits it, attributed via a + /// salted per-user pseudonym; users can exclude themselves with + /// `traces opt-out`. + EnrollInstance { + /// Operator invite link (`https://#`, or `@`) + #[arg(long)] + invite: String, + + /// Include locally redacted user/assistant message text in envelopes + /// (applies instance-wide to every inheriting user) + #[arg(long)] + include_message_text: bool, + + /// Include locally redacted tool arguments, tool results, and HTTP + /// bodies in envelopes (applies instance-wide) + #[arg(long)] + include_tool_payloads: bool, + + /// Output the enrollment outcome as JSON + #[arg(long)] + json: bool, + }, + /// Show local standing trace contribution policy Status { /// Output as JSON @@ -399,6 +424,7 @@ async fn run_traces(cmd: TracesSubcommand) -> anyhow::Result<()> { match cmd { v @ TracesSubcommand::OptIn { .. } => contributor::dispatch(v).await, v @ TracesSubcommand::OptOut { .. } => contributor::dispatch(v).await, + v @ TracesSubcommand::EnrollInstance { .. } => contributor::dispatch(v).await, v @ TracesSubcommand::Status { .. } => contributor::dispatch(v).await, v @ TracesSubcommand::Preview { .. } => contributor::dispatch(v).await, v @ TracesSubcommand::Enqueue { .. } => contributor::dispatch(v).await, @@ -534,6 +560,67 @@ fn opt_out(user_scope: Option<&str>) -> anyhow::Result<()> { Ok(()) } +/// Instance-wide Trace Commons enrollment via an operator invite link. +/// Host-shell possession is the admin gate (same trust boundary as `opt-in` +/// writing the global policy). Writes the DeviceKey policy + device keypair +/// at the instance (scope-`None`) location, which every user without a +/// personal enrollment inherits with a salted per-user pseudonymous subject. +async fn enroll_instance( + invite: &str, + include_message_text: bool, + include_tool_payloads: bool, + json: bool, +) -> anyhow::Result<()> { + let consents = ironclaw_reborn_traces::onboarding::OnboardConsents { + include_message_text, + include_tool_payloads, + }; + let outcome = ironclaw_reborn_traces::onboarding::onboard_instance_at_base( + &ironclaw_reborn_traces::paths::ironclaw_base_dir(), + invite, + consents, + ) + .await + .map_err(|e| anyhow::anyhow!("instance enrollment failed: {e}"))?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "enrolled": true, + "tenant_id": outcome.tenant_id, + "ingest_url": outcome.ingest_url, + "issuer_url": outcome.issuer_url, + "device_key_id": outcome.device_key_id, + "include_message_text": include_message_text, + "include_tool_payloads": include_tool_payloads, + })) + .map_err(|e| anyhow::anyhow!("failed to serialize enrollment outcome: {e}"))? + ); + return Ok(()); + } + + println!("Instance enrolled in Trace Commons."); + println!(" tenant: {}", outcome.tenant_id); + println!(" ingest: {}", outcome.ingest_url); + println!(" issuer: {}", outcome.issuer_url); + println!(" device key: {}", outcome.device_key_id); + println!( + " consents: message_text={include_message_text} tool_payloads={include_tool_payloads}" + ); + println!(); + println!( + "All users without a personal enrollment now contribute under this instance \ + enrollment, attributed via salted per-user pseudonyms." + ); + println!( + "Users can exclude themselves with `ironclaw-reborn traces opt-out`; an explicit \ + opt-out always wins over instance enrollment." + ); + println!("Verify with `ironclaw-reborn traces status --json` and `traces ingest-health`."); + Ok(()) +} + fn show_policy_status(json: bool, user_scope: Option<&str>) -> anyhow::Result<()> { let normalized_scope = user_scope .map(str::trim) diff --git a/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs b/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs index c9ab20ab0f2..f2b2acc91a5 100644 --- a/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs +++ b/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs @@ -853,3 +853,40 @@ fn queue_status_diagnostics_reports_invite_code_configured() { "absent invite code must report configured=false" ); } + +#[test] +fn enroll_instance_parses_invite_and_consent_flags() { + let cli = parse_cli([ + "ironclaw-reborn", + "traces", + "enroll-instance", + "--invite", + "https://commons.example#INVADMIN1", + "--include-message-text", + "--json", + ]); + + let TracesSubcommand::EnrollInstance { + invite, + include_message_text, + include_tool_payloads, + json, + } = unwrap_traces_command(cli) + else { + panic!("expected traces enroll-instance command"); + }; + + assert_eq!(invite, "https://commons.example#INVADMIN1"); + assert!(include_message_text); + assert!( + !include_tool_payloads, + "tool payloads must default to excluded" + ); + assert!(json); +} + +#[test] +fn enroll_instance_requires_invite() { + let result = parse_cli_result(["ironclaw-reborn", "traces", "enroll-instance"]); + assert!(result.is_err(), "--invite must be required"); +} diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 95a5f47dd87..42319b19e8b 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -3992,7 +3992,10 @@ fn is_zero_u32(value: &u32) -> bool { *value == 0 } -fn trace_contribution_dir_for_scope_at(base: &std::path::Path, scope: Option<&str>) -> PathBuf { +pub(crate) fn trace_contribution_dir_for_scope_at( + base: &std::path::Path, + scope: Option<&str>, +) -> PathBuf { let contributions = base.join("trace_contributions"); match scope { Some(scope) if !scope.trim().is_empty() => { @@ -5551,6 +5554,85 @@ impl std::fmt::Display for ContributionHttpError { impl std::error::Error for ContributionHttpError {} +/// Direct-transport [`ContributionHttpSink`] for trusted non-agent surfaces +/// (WebUI facades, CLI). Applies the same hardening as the other direct +/// clients in this module: per-request pinned DNS resolution with +/// private/internal-IP rejection (`resolve_trace_upload_claim_issuer_host`), +/// no redirects, the request's own timeout, and a body read bounded DURING +/// streaming by the request's `response_body_limit`. Agent-path callers must +/// keep using the host-egress sink instead. +pub struct DirectPinnedContributionSink; + +#[async_trait] +impl ContributionHttpSink for DirectPinnedContributionSink { + async fn execute( + &self, + request: ContributionHttpRequest, + ) -> Result { + let url = reqwest::Url::parse(&request.url) + .map_err(|e| ContributionHttpError::new(format!("invalid request URL: {e}")))?; + let host = url + .host_str() + .ok_or_else(|| ContributionHttpError::new("request URL requires a host"))? + .to_ascii_lowercase(); + let port = url + .port_or_known_default() + .ok_or_else(|| ContributionHttpError::new("request URL requires a known port"))?; + let resolved_addrs = resolve_trace_upload_claim_issuer_host(&host, port) + .await + .map_err(|e| ContributionHttpError::new(format!("host resolution rejected: {e}")))?; + let timeout = Duration::from_millis(u64::from(request.timeout_ms)); + let client = reqwest::Client::builder() + .timeout(timeout) + .connect_timeout(timeout.min(Duration::from_secs(3))) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("ironclaw-trace-commons-client") + .resolve_to_addrs(&host, &resolved_addrs) + .build() + .map_err(|e| ContributionHttpError::new(format!("failed to build client: {e}")))?; + + let method = match request.method { + ContributionHttpMethod::Get => reqwest::Method::GET, + ContributionHttpMethod::Post => reqwest::Method::POST, + ContributionHttpMethod::Put => reqwest::Method::PUT, + ContributionHttpMethod::Delete => reqwest::Method::DELETE, + }; + let mut builder = client + .request(method, url) + .header(reqwest::header::ACCEPT, "application/json"); + if let Some(token) = request.bearer_token { + builder = builder.bearer_auth(token); + } + if let Some(body) = request.json_body { + builder = builder + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + let mut response = builder + .send() + .await + .map_err(|e| ContributionHttpError::new(format!("request failed: {e}")))?; + let status = response.status().as_u16(); + // Enforce the cap DURING the chunked read so a hostile server cannot + // force a large allocation by streaming an oversized body. + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| ContributionHttpError::new(format!("response read failed: {e}")))? + { + if body.len() as u64 + chunk.len() as u64 > request.response_body_limit { + return Err(ContributionHttpError::new(format!( + "response body exceeds the {} byte limit", + request.response_body_limit + ))); + } + body.extend_from_slice(&chunk); + } + Ok(ContributionHttpResponse { status, body }) + } +} + /// Decode a host-egress response body into a bounded UTF-8 string, capping at /// `TRACE_UPLOAD_CLAIM_MAX_RESPONSE_BYTES` (the host egress already enforced the /// limit, but truncating defensively keeps a hostile body bounded). Lossy @@ -6638,6 +6720,40 @@ pub async fn mint_account_login_link_via_sink( .await } +/// Direct (non-agent) counterpart to [`mint_account_login_link_via_sink`] +/// for WebUI facades and other trusted product surfaces: mints the one-time +/// login link through the [`DirectPinnedContributionSink`] (pinned DNS, +/// private-IP filtering) instead of a host-egress sink. +/// +/// Delivery contract: the link is returned ONLY in the result — it is never +/// persisted to a local delivery file. Hosted multi-tenant users cannot read +/// host files; the caller (an authenticated WebUI response) is the delivery +/// channel. The URL must never be logged or placed on any model-visible +/// surface. +pub async fn mint_account_login_link( + tenant_id: &TenantId, + user_id: &UserId, +) -> Result { + // Typed at the public boundary so callers can't transpose tenant/user; + // stringify only when handing off to the dir-parameterised core. + mint_account_login_link_direct( + ironclaw_common::paths::ironclaw_base_dir().as_path(), + tenant_id.as_str(), + user_id.as_str(), + ) + .await +} + +/// Dir-parameterised core for [`mint_account_login_link`] (direct path). +/// Accepts an explicit `base_dir` so tests can supply an isolated tempdir. +async fn mint_account_login_link_direct( + base_dir: &std::path::Path, + tenant_id: &str, + user_id: &str, +) -> Result { + mint_account_login_link_inner(base_dir, tenant_id, user_id, &DirectPinnedContributionSink).await +} + /// Dir-parameterised core for [`mint_account_login_link_via_sink`]. /// Accepts an explicit `base_dir` so tests can supply an isolated tempdir. async fn mint_account_login_link_inner( @@ -16324,17 +16440,58 @@ mod tests { ); } - let bodies = captured.lock().unwrap(); - assert_eq!(bodies.len(), 1, "exactly one POST to login-links"); - let expected_subject = salted_pseudonymous_contributor_id_at( - base.path(), - &trace_scope_key("tenant-dev", "alice"), - ) - .unwrap(); + { + let bodies = captured.lock().unwrap(); + assert_eq!(bodies.len(), 1, "exactly one POST to login-links"); + let expected_subject = salted_pseudonymous_contributor_id_at( + base.path(), + &trace_scope_key("tenant-dev", "alice"), + ) + .unwrap(); + assert_eq!( + bodies[0]["subject"], + serde_json::Value::String(expected_subject), + "posted subject must be per-user pseudonymous id for instance enrollment" + ); + } + + // ── direct (WebUI facade) variant ──────────────────────────────────── + // Same enrollment, no sink: the hosted-WebUI path mints through the + // pinned direct client. The link is delivered ONLY in the return value + // (the authenticated HTTP response) — it must never be persisted to a + // local delivery file, which hosted users cannot read. + let direct = mint_account_login_link_direct(base.path(), "tenant-dev", "alice") + .await + .expect("direct login-link mint succeeds"); + assert_eq!(direct.url, "/account/login?code=abc"); + assert_eq!(direct.account_id, "11111111-1111-1111-1111-111111111111"); + let mut delivery_files = Vec::new(); + let mut stack = vec![base.path().to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("account_login_link.")) + { + delivery_files.push(path); + } + } + } + assert!( + delivery_files.is_empty(), + "direct mint must not write a local delivery file; found {delivery_files:?}" + ); assert_eq!( - bodies[0]["subject"], - serde_json::Value::String(expected_subject), - "posted subject must be per-user pseudonymous id for instance enrollment" + captured.lock().unwrap().len(), + 2, + "direct mint must POST to login-links too" ); } diff --git a/crates/ironclaw_reborn_traces/src/onboarding/mod.rs b/crates/ironclaw_reborn_traces/src/onboarding/mod.rs index 7dd87b255df..34e380f3c1b 100644 --- a/crates/ironclaw_reborn_traces/src/onboarding/mod.rs +++ b/crates/ironclaw_reborn_traces/src/onboarding/mod.rs @@ -193,8 +193,9 @@ pub async fn onboard( /// (`trace_contribution_dir_for_scope(None)`), so all users without their own /// personal-invite enrollment inherit it via `resolve_trace_credentials`. /// -/// This is an admin-only operation at the call boundary (the host gates it -/// behind `AdminScope`); the function itself only knows it targets the base dir. +/// This is an admin-only operation at the call boundary (the caller must gate +/// it: host-shell possession for the CLI, an admin identity for any future +/// product surface); the function itself only knows it targets the base dir. pub async fn onboard_instance_with_sink( invite_url: &str, consents: OnboardConsents, @@ -204,6 +205,22 @@ pub async fn onboard_instance_with_sink( onboard_at_dir_with_sink(&dir, invite_url, consents, sink).await } +/// Base-dir-parameterised instance enrollment using the default +/// direct-`reqwest` sink — the admin CLI path (`ironclaw-reborn traces +/// enroll-instance`), where host-shell possession is the admin gate and there +/// is no host egress pipeline to route through. Targets +/// `/trace_contributions/` (the scope-`None` location), so all users +/// without a personal enrollment inherit it via `resolve_trace_credentials`. +/// Tests supply an isolated tempdir base. +pub async fn onboard_instance_at_base( + base_dir: &Path, + invite_url: &str, + consents: OnboardConsents, +) -> Result { + let dir = crate::contribution::trace_contribution_dir_for_scope_at(base_dir, None); + onboard_at_dir(&dir, invite_url, consents).await +} + /// Dir-parameterised core using the default direct-`reqwest` sink — /// unit-testable with tempdirs (loopback mocks). Thin wrapper around /// [`onboard_at_dir_with_sink`]. diff --git a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs index 47f4fbcbed4..24483bbc8a8 100644 --- a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs +++ b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs @@ -878,3 +878,53 @@ async fn instance_onboard_writes_instance_level_policy() { ); // tempdir drops automatically — no manual cleanup needed } + +/// The admin CLI entry point: enrollment via `onboard_instance_at_base` must +/// land the policy + device key at the INSTANCE location +/// (`/trace_contributions/`, scope `None`) — not a `users//` +/// scope dir — so every non-personally-enrolled user inherits it via +/// `resolve_trace_credentials` (inheritance itself is pinned by the resolver +/// tests in `contribution.rs`). +#[tokio::test] +async fn onboard_instance_at_base_targets_the_instance_dir() { + let base = tempfile::tempdir().unwrap(); + let mock = spawn_mock_issuer( + |addr| ok_response(addr, "https://ingest.example.com"), + axum::http::StatusCode::OK, + ) + .await; + let invite_url = format!("http://127.0.0.1:{}/onboard#INVADMIN1", mock.addr.port()); + let consents = OnboardConsents { + include_message_text: false, + include_tool_payloads: false, + }; + + let outcome = onboard_instance_at_base(base.path(), &invite_url, consents) + .await + .expect("instance onboard succeeds"); + assert_eq!(outcome.tenant_id, "tenant-a"); + + let instance_dir = base.path().join("trace_contributions"); + let policy_path = instance_dir.join("policy.json"); + assert!( + policy_path.exists(), + "policy must land at the instance (scope-None) location" + ); + assert!( + !base.path().join("trace_contributions/users").exists(), + "instance enrollment must not create any per-user scope dir" + ); + let policy: StandingTraceContributionPolicy = + serde_json::from_str(&std::fs::read_to_string(&policy_path).unwrap()).unwrap(); + assert!(policy.enabled); + assert_eq!(policy.auth_mode, TraceUploadAuthMode::DeviceKey); + assert!(!policy.include_message_text); + assert!(!policy.include_tool_payloads); + + // Promoted instance device key exists at the instance dir. + let tenant_key = instance_dir.join(format!( + "device_keys/{}.json", + device_key::tenant_hash("tenant-a") + )); + assert!(tenant_key.exists(), "instance device key must be promoted"); +} diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.mjs b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.mjs deleted file mode 100644 index 11032d582e6..00000000000 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import vm from "node:vm"; -import { test } from "vitest"; - -// Load the pure helpers out of the component module without executing its -// imports (html/hooks/design-system are browser-side). Mirrors the harness in -// provider-components.test.mts: strip imports, de-export, expose test exports. -function sourceForTest(path, exportNames) { - const source = readFileSync(new URL(path, import.meta.url), "utf8"); - const lines = []; - let skippingImport = false; - for (const line of source.split("\n")) { - if (!skippingImport && line.startsWith("import ")) { - skippingImport = !line.trimEnd().endsWith(";"); - continue; - } - if (skippingImport) { - skippingImport = !line.trimEnd().endsWith(";"); - continue; - } - lines.push(line.replace(/^export function /, "function ")); - } - return `${lines.join("\n")}\nglobalThis.__testExports = { ${exportNames.join(", ")} };`; -} - -const context = { html: () => null }; -context.globalThis = context; -vm.createContext(context); -vm.runInContext( - sourceForTest("./trace-commons-tab.ts", [ - "tracesSectionMode", - "formatCredit", - "formatTimestamp", - ]), - context -); -const { tracesSectionMode, formatCredit, formatTimestamp } = context.__testExports; - -const TRACE = { submission_id: "s1", status: "accepted" }; - -test("traces load error always surfaces, never hides behind an empty state", () => { - assert.equal( - tracesSectionMode({ isError: true, enrolled: true, traces: [TRACE] }), - "error" - ); - // Error wins even for unenrolled/empty views — a backend failure must not - // render as "no traces". - assert.equal( - tracesSectionMode({ isError: true, enrolled: false, traces: [] }), - "error" - ); -}); - -test("enrolled contributor with traces renders the trace list", () => { - assert.equal( - tracesSectionMode({ isError: false, enrolled: true, traces: [TRACE] }), - "list" - ); -}); - -test("section hides for empty or unenrolled states", () => { - assert.equal( - tracesSectionMode({ isError: false, enrolled: true, traces: [] }), - "hidden" - ); - assert.equal( - tracesSectionMode({ isError: false, enrolled: false, traces: [TRACE] }), - "hidden" - ); - assert.equal( - tracesSectionMode({ isError: false, enrolled: false, traces: undefined }), - "hidden" - ); -}); - -test("credit and timestamp formatting used by trace rows", () => { - assert.equal(formatCredit(1), "1.00"); - assert.equal(formatCredit("2.5"), "2.50"); - assert.equal(formatCredit(null), "0.00"); - assert.equal(formatCredit("not-a-number"), "0.00"); - - const t = (key) => key; - assert.equal(formatTimestamp(null, t), "traceCommons.never"); - assert.equal(formatTimestamp("garbage", t), "traceCommons.never"); - assert.notEqual(formatTimestamp("2026-06-25T00:00:00Z", t), "traceCommons.never"); -}); diff --git a/docs/superpowers/plans/2026-06-25-trace-commons-slice1-resolver-instance-enrollment.md b/docs/superpowers/plans/2026-06-25-trace-commons-slice1-resolver-instance-enrollment.md index 39967e2c2f8..5b7e2d9d927 100644 --- a/docs/superpowers/plans/2026-06-25-trace-commons-slice1-resolver-instance-enrollment.md +++ b/docs/superpowers/plans/2026-06-25-trace-commons-slice1-resolver-instance-enrollment.md @@ -292,7 +292,10 @@ git commit -m "feat(traces): instance-level enrollment write path (scope None)" > not land in the retiring v1 monolith. The crate-side entry point is > `ironclaw_reborn_traces::onboarding::onboard_instance_with_sink` (Task 2); > an admin-gated Reborn surface will wrap it when instance enrollment gets a -> product entry point. The original task text is retained below only as a +> product entry point. That surface now exists: `ironclaw-reborn traces +> enroll-instance --invite ` wraps +> `onboarding::onboard_instance_at_base` (host-shell possession is the admin +> gate, matching `traces opt-in`'s trust boundary for the global policy). The original task text is retained below only as a > historical record of what was planned. **Files:** @@ -392,7 +395,10 @@ git commit -m "feat(admin): AdminScope::enroll_instance_trace_commons (admin-gat > in the retiring v1 monolith. The crate-side entry point is > `ironclaw_reborn_traces::onboarding::onboard_instance_with_sink`; an > admin-gated Reborn surface will wrap it when instance enrollment gets a -> product entry point. +> product entry point. That surface now exists: `ironclaw-reborn traces +> enroll-instance --invite ` wraps +> `onboarding::onboard_instance_at_base` (host-shell possession is the admin +> gate, matching `traces opt-in`'s trust boundary for the global policy). --- From 1b94f157b0926987878a9530e29427743d2645b9 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 01:07:58 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(webui):=20Open=20Trace=20Commons=20acc?= =?UTF-8?q?ount=20from=20the=20WebUI=20=E2=80=94=20hosted-user=20login=20l?= =?UTF-8?q?inks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted multi-tenant users have no host shell, and the agent capability delivers the one-time login URL to a local file they cannot read. This adds the product path: an authenticated WebUI action that mints the link and returns it directly to the caller's browser — the URL never touches a local file, a log line, or any model-visible surface. - product_workflow: account_login_link_for_user + RebornServicesApi::trace_account_login_link (caller-derived scope; unenrolled → zero-state, not an error). - webui_v2: POST /api/webchat/v2/traces/account-login-link (webui.v2.trace_account_login_link), NoBody, per-caller 10/min rate limit (each call mints a credential). Descriptor + handler contract tests; route table row in CLAUDE.md. - frontend: 'Open Trace Commons account' button on the Trace Commons settings tab. openAccountLoginLink opens a blank tab synchronously (popup-blocker attribution) with noopener,noreferrer, then navigates it to the minted URL; closes the placeholder on unenrolled/error. i18n for all 11 languages. - test-runner fix: trace-commons-tab.test.mjs had silently stopped running after the frontend migration (vitest include is *.{test,spec}.{ts,tsx}); converted to trace-commons-tab.test.ts with the window.open fake capturing every argument the production caller passes. Suite: 582 → 590. Co-Authored-By: Claude Fable 5 --- crates/ironclaw_product_workflow/src/lib.rs | 15 ++- .../src/reborn_services.rs | 27 +++- .../src/reborn_services/trace_credits.rs | 58 ++++++++- crates/ironclaw_webui_v2/CLAUDE.md | 1 + .../ironclaw_webui_v2/frontend/src/i18n/ar.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/de.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/en.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/es.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/fr.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/hi.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/ja.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/ko.ts | 4 + .../frontend/src/i18n/pt-BR.ts | 4 + .../ironclaw_webui_v2/frontend/src/i18n/uk.ts | 4 + .../frontend/src/i18n/zh-CN.ts | 4 + .../components/trace-commons-tab.test.ts | 121 ++++++++++++++++++ .../settings/components/trace-commons-tab.tsx | 56 ++++++++ .../src/pages/settings/lib/settings-api.ts | 7 + crates/ironclaw_webui_v2/src/descriptors.rs | 21 +++ crates/ironclaw_webui_v2/src/handlers.rs | 84 +++++++----- crates/ironclaw_webui_v2/src/lib.rs | 10 +- crates/ironclaw_webui_v2/src/router.rs | 9 +- .../tests/webui_v2_descriptors_contract.rs | 25 +++- .../tests/webui_v2_handlers_contract.rs | 90 ++++++++++--- 24 files changed, 497 insertions(+), 71 deletions(-) create mode 100644 crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index 8c01b2469a1..7c3a7c3ad10 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -169,13 +169,14 @@ pub use reborn_services::{ OperatorServiceLifecycleService, OperatorStatusService, OutboundPreferencesProductFacade, ProductAgentBoundCaller, ProjectCaller, ProjectFilesystemReader, ProjectFsEntry, ProjectFsEntryKind, ProjectFsError, ProjectFsFile, ProjectFsStat, ProjectService, - ProjectServiceError, RebornAccountTrace, RebornAccountTracesResponse, RebornAddMemberRequest, - RebornAdminCreateUserRequest, RebornAdminPutSecretRequest, RebornAdminSecretDeletedResponse, - RebornAdminSecretResponse, RebornAdminSetRoleRequest, RebornAdminSetStatusRequest, - RebornAdminUpdateUserRequest, RebornAdminUserCreatedResponse, RebornAdminUserDeletedResponse, - RebornAdminUserListQuery, RebornAdminUserListResponse, RebornAdminUserResponse, - RebornAdminUserSecretsListResponse, RebornAttachmentBytes, RebornAttachmentRequest, - RebornAutomationInfo, RebornAutomationMutationResponse, RebornAutomationRecentRunInfo, + ProjectServiceError, RebornAccountLoginLinkResponse, RebornAccountTrace, + RebornAccountTracesResponse, RebornAddMemberRequest, RebornAdminCreateUserRequest, + RebornAdminPutSecretRequest, RebornAdminSecretDeletedResponse, RebornAdminSecretResponse, + RebornAdminSetRoleRequest, RebornAdminSetStatusRequest, RebornAdminUpdateUserRequest, + RebornAdminUserCreatedResponse, RebornAdminUserDeletedResponse, RebornAdminUserListQuery, + RebornAdminUserListResponse, RebornAdminUserResponse, RebornAdminUserSecretsListResponse, + RebornAttachmentBytes, RebornAttachmentRequest, RebornAutomationInfo, + RebornAutomationMutationResponse, RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 8252db5e7ef..cdbf9b19fbb 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -91,8 +91,8 @@ pub use admin_users::{ }; pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind}; pub use trace_credits::{ - RebornAccountTrace, RebornAccountTracesResponse, RebornTraceCreditsResponse, - RebornTraceHoldAuthorizeResponse, + RebornAccountLoginLinkResponse, RebornAccountTrace, RebornAccountTracesResponse, + RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse, }; pub use fs_browse::{ @@ -2097,6 +2097,29 @@ pub trait RebornServicesApi: Send + Sync { .map_err(RebornServicesError::internal_from) } + /// Mint a one-time Trace Commons browser login link for the + /// authenticated caller, so hosted users (no host-file access) can open + /// their contributor account from the WebUI. + /// + /// The scope is always derived from the authenticated caller's tenant + + /// user id — never from request input. An unenrolled caller gets the + /// zero-state (`minted: false, enrolled: false`), never an error. + /// + /// SECURITY: the returned URL is a one-time account-access credential. It + /// travels only over this authenticated response to the caller's own + /// browser; it must never be logged or exposed on a model-visible + /// surface. The default body is the production implementation (pinned + /// direct client, same lane as `trace_account_traces`). + async fn trace_account_login_link( + &self, + caller: WebUiAuthenticatedCaller, + ) -> Result { + let actor = caller.actor(); + trace_credits::account_login_link_for_user(&caller.tenant_id, &actor.user_id) + .await + .map_err(RebornServicesError::internal_from) + } + /// Authorize the caller's held manual-review trace for submission /// (promote-as-is). The scope is always the authenticated caller's user /// id; the submission id from the request path is never authority to diff --git a/crates/ironclaw_product_workflow/src/reborn_services/trace_credits.rs b/crates/ironclaw_product_workflow/src/reborn_services/trace_credits.rs index 2aa857e1918..1061be35060 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/trace_credits.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/trace_credits.rs @@ -10,8 +10,9 @@ use chrono::{DateTime, Utc}; use ironclaw_host_api::{TenantId, UserId}; use ironclaw_reborn_traces::contribution::{ - authorize_manual_review_hold_for_scope, fetch_account_traces, read_trace_policy_for_scope, - resolve_trace_credentials, scoped_credit_view, + AccountLoginLinkError, authorize_manual_review_hold_for_scope, fetch_account_traces, + mint_account_login_link, read_trace_policy_for_scope, resolve_trace_credentials, + scoped_credit_view, }; use serde::{Deserialize, Serialize}; @@ -101,6 +102,59 @@ pub struct RebornTraceHoldAuthorizeResponse { pub authorized: bool, } +/// One-time Trace Commons browser login link, minted for the authenticated +/// caller. SECURITY: the `url` is a code-bearing account-access credential. +/// It is delivered ONLY over the authenticated WebUI response to the caller's +/// own browser — it must never be logged, persisted, or placed on any +/// model-visible surface. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RebornAccountLoginLinkResponse { + /// Whether a link was minted. `false` with `enrolled: false` is the + /// unenrolled zero-state, not an error. + pub minted: bool, + pub enrolled: bool, + /// The one-time login URL (present iff `minted`). Expires shortly and is + /// single-use. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// Typed failure for [`account_login_link_for_user`]. Mirrors +/// [`AccountTracesError`]: the operation is named and the cause chain is +/// preserved for a diagnosable (but sanitized at the wire) 500. +#[derive(Debug, thiserror::Error)] +pub(super) enum AccountLoginLinkMintError { + /// Minting the link failed (policy read, claim mint, transport, issuer). + #[error("mint account login link: {0}")] + Mint(String), +} + +/// Mint a one-time Trace Commons browser login link for the caller. +/// +/// Uses the crate-local pinned direct client (no host-egress sink) — the same +/// network lane as [`account_traces_for_user`]. An unenrolled caller gets the +/// zero-state (`minted: false, enrolled: false`), never an error; hosted +/// multi-tenant users have no host-file access, so the authenticated response +/// is the only delivery channel for the link. +pub(super) async fn account_login_link_for_user( + tenant_id: &TenantId, + user_id: &UserId, +) -> Result { + match mint_account_login_link(tenant_id, user_id).await { + Ok(link) => Ok(RebornAccountLoginLinkResponse { + minted: true, + enrolled: true, + url: Some(link.url), + }), + Err(AccountLoginLinkError::NotEnrolled) => Ok(RebornAccountLoginLinkResponse { + minted: false, + enrolled: false, + url: None, + }), + Err(error) => Err(AccountLoginLinkMintError::Mint(format!("{error:#}"))), + } +} + /// Typed failure for [`account_traces_for_user`]. Each variant names the backend /// operation that failed and carries the full cause chain (`{:#}`) so the WebUI /// boundary keeps a diagnosable cause instead of an undiscriminated `String`. diff --git a/crates/ironclaw_webui_v2/CLAUDE.md b/crates/ironclaw_webui_v2/CLAUDE.md index 6c2c418ff84..0ef89a863c5 100644 --- a/crates/ironclaw_webui_v2/CLAUDE.md +++ b/crates/ironclaw_webui_v2/CLAUDE.md @@ -114,6 +114,7 @@ user's one-time API bearer exactly once in `api_token`; there is no re-issue endpoint for existing users. | `webui.v2.trace_credits` | GET | `/api/webchat/v2/traces/credit` | None | `ProductWorkflow` | | `webui.v2.trace_account_traces` | GET | `/api/webchat/v2/traces/account` (optional `?limit=N`) | None | `ProductWorkflow` | +| `webui.v2.trace_account_login_link` | POST | `/api/webchat/v2/traces/account-login-link` | None | `ProductWorkflow` | | `webui.v2.authorize_trace_hold` | POST | `/api/webchat/v2/traces/holds/{submission_id}/authorize` | None | `ProductWorkflow` | `webui.v2.logs` accepts bounded `limit`, `cursor`, `level`, and `target` diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts index 16896162f07..05b507a5c6e 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ar.ts @@ -252,6 +252,10 @@ registerPack("ar", { "traceCommons.lastSyncDesc": "عرض محلي حتى آخر مزامنة", "traceCommons.never": "أبدًا", "traceCommons.recentExplanations": "تفسيرات الرصيد الأخيرة", + "traceCommons.openAccount": "فتح حساب Trace Commons", + "traceCommons.openingAccount": "جارٍ الفتح…", + "traceCommons.openAccountFailed": + "تعذر فتح حساب Trace Commons الخاص بك. تحقق من التسجيل وحاول مرة أخرى.", "traceCommons.submittedTracesTitle": "الآثار المُرسَلة", "traceCommons.tracesLoadFailed": "تعذّر تحميل آثارك المُرسَلة.", "traceCommons.traceStatus": "الحالة", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts index f3f9f9137bc..29b2bd8866f 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/de.ts @@ -252,6 +252,10 @@ registerPack("de", { "traceCommons.lastSyncDesc": "Lokale Ansicht zum Zeitpunkt der letzten Synchronisierung", "traceCommons.never": "nie", "traceCommons.recentExplanations": "Aktuelle Guthaben-Erläuterungen", + "traceCommons.openAccount": "Trace-Commons-Konto öffnen", + "traceCommons.openingAccount": "Wird geöffnet…", + "traceCommons.openAccountFailed": + "Ihr Trace-Commons-Konto konnte nicht geöffnet werden. Prüfen Sie die Registrierung und versuchen Sie es erneut.", "traceCommons.submittedTracesTitle": "Eingereichte Traces", "traceCommons.tracesLoadFailed": "Ihre eingereichten Traces konnten nicht geladen werden.", "traceCommons.traceStatus": "Status", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts index 603e7e889a7..3898e0c1016 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/en.ts @@ -263,6 +263,10 @@ registerPack("en", { "traceCommons.recentExplanations": "Recent credit explanations", "traceCommons.note": "Local view as of last sync — the authoritative credit ledger is server-side. Final credit can change after privacy review, replay/eval, duplicate checks, and downstream utility scoring.", + "traceCommons.openAccount": "Open Trace Commons account", + "traceCommons.openingAccount": "Opening…", + "traceCommons.openAccountFailed": + "Could not open your Trace Commons account. Check enrollment and try again.", "traceCommons.submittedTracesTitle": "Submitted traces", "traceCommons.tracesLoadFailed": "Could not load your submitted traces.", "traceCommons.traceStatus": "Status", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts index 19113a0de4d..28395e5a17d 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/es.ts @@ -252,6 +252,10 @@ registerPack("es", { "traceCommons.lastSyncDesc": "Vista local a fecha de la última sincronización", "traceCommons.never": "nunca", "traceCommons.recentExplanations": "Explicaciones de crédito recientes", + "traceCommons.openAccount": "Abrir cuenta de Trace Commons", + "traceCommons.openingAccount": "Abriendo…", + "traceCommons.openAccountFailed": + "No se pudo abrir tu cuenta de Trace Commons. Comprueba la inscripción e inténtalo de nuevo.", "traceCommons.submittedTracesTitle": "Trazas enviadas", "traceCommons.tracesLoadFailed": "No se pudieron cargar tus trazas enviadas.", "traceCommons.traceStatus": "Estado", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts index 07e807b8998..fd04cd27f24 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/fr.ts @@ -252,6 +252,10 @@ registerPack("fr", { "traceCommons.lastSyncDesc": "Vue locale à la dernière synchronisation", "traceCommons.never": "jamais", "traceCommons.recentExplanations": "Explications de crédit récentes", + "traceCommons.openAccount": "Ouvrir le compte Trace Commons", + "traceCommons.openingAccount": "Ouverture…", + "traceCommons.openAccountFailed": + "Impossible d'ouvrir votre compte Trace Commons. Vérifiez l'inscription et réessayez.", "traceCommons.submittedTracesTitle": "Traces soumises", "traceCommons.tracesLoadFailed": "Impossible de charger vos traces soumises.", "traceCommons.traceStatus": "Statut", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts index b418274bf34..9b5e33a3d2d 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/hi.ts @@ -252,6 +252,10 @@ registerPack("hi", { "traceCommons.lastSyncDesc": "अंतिम सिंक के समय का स्थानीय दृश्य", "traceCommons.never": "कभी नहीं", "traceCommons.recentExplanations": "हाल की क्रेडिट व्याख्याएँ", + "traceCommons.openAccount": "Trace Commons खाता खोलें", + "traceCommons.openingAccount": "खोला जा रहा है…", + "traceCommons.openAccountFailed": + "आपका Trace Commons खाता नहीं खोला जा सका। नामांकन जाँचें और फिर से प्रयास करें।", "traceCommons.submittedTracesTitle": "सबमिट किए गए ट्रेस", "traceCommons.tracesLoadFailed": "आपके सबमिट किए गए ट्रेस लोड नहीं हो सके।", "traceCommons.traceStatus": "स्थिति", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts index d744a83fef1..81da3d8e35f 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ja.ts @@ -252,6 +252,10 @@ registerPack("ja", { "traceCommons.lastSyncDesc": "最終同期時点のローカル表示", "traceCommons.never": "なし", "traceCommons.recentExplanations": "最近のクレジット明細", + "traceCommons.openAccount": "Trace Commons アカウントを開く", + "traceCommons.openingAccount": "開いています…", + "traceCommons.openAccountFailed": + "Trace Commons アカウントを開けませんでした。登録状況を確認して再試行してください。", "traceCommons.submittedTracesTitle": "送信したトレース", "traceCommons.tracesLoadFailed": "送信したトレースを読み込めませんでした。", "traceCommons.traceStatus": "ステータス", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts index 8d8ddb1fbdb..8623eec34a5 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/ko.ts @@ -252,6 +252,10 @@ registerPack("ko", { "traceCommons.lastSyncDesc": "마지막 동기화 기준 로컬 보기", "traceCommons.never": "없음", "traceCommons.recentExplanations": "최근 크레딧 내역", + "traceCommons.openAccount": "Trace Commons 계정 열기", + "traceCommons.openingAccount": "여는 중…", + "traceCommons.openAccountFailed": + "Trace Commons 계정을 열 수 없습니다. 등록 상태를 확인하고 다시 시도하세요.", "traceCommons.submittedTracesTitle": "제출한 트레이스", "traceCommons.tracesLoadFailed": "제출한 트레이스를 불러오지 못했습니다.", "traceCommons.traceStatus": "상태", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts index 050c1a74634..73e311f32c5 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/pt-BR.ts @@ -252,6 +252,10 @@ registerPack("pt-BR", { "traceCommons.lastSyncDesc": "Visão local na última sincronização", "traceCommons.never": "nunca", "traceCommons.recentExplanations": "Explicações de crédito recentes", + "traceCommons.openAccount": "Abrir conta do Trace Commons", + "traceCommons.openingAccount": "Abrindo…", + "traceCommons.openAccountFailed": + "Não foi possível abrir sua conta do Trace Commons. Verifique a inscrição e tente novamente.", "traceCommons.submittedTracesTitle": "Traces enviados", "traceCommons.tracesLoadFailed": "Não foi possível carregar seus traces enviados.", "traceCommons.traceStatus": "Status", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts index 26550aa38ac..7e5299061cf 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/uk.ts @@ -252,6 +252,10 @@ registerPack("uk", { "traceCommons.lastSyncDesc": "Локальний перегляд станом на останню синхронізацію", "traceCommons.never": "ніколи", "traceCommons.recentExplanations": "Останні пояснення кредитів", + "traceCommons.openAccount": "Відкрити обліковий запис Trace Commons", + "traceCommons.openingAccount": "Відкриття…", + "traceCommons.openAccountFailed": + "Не вдалося відкрити ваш обліковий запис Trace Commons. Перевірте реєстрацію та спробуйте ще раз.", "traceCommons.submittedTracesTitle": "Надіслані трейси", "traceCommons.tracesLoadFailed": "Не вдалося завантажити ваші надіслані трейси.", "traceCommons.traceStatus": "Статус", diff --git a/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts b/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts index 64f6d54b3e4..2d5b414cd0a 100644 --- a/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts +++ b/crates/ironclaw_webui_v2/frontend/src/i18n/zh-CN.ts @@ -252,6 +252,10 @@ registerPack("zh-CN", { "traceCommons.lastSyncDesc": "截至最近同步的本地视图", "traceCommons.never": "从未", "traceCommons.recentExplanations": "最近的积分说明", + "traceCommons.openAccount": "打开 Trace Commons 账户", + "traceCommons.openingAccount": "正在打开…", + "traceCommons.openAccountFailed": + "无法打开您的 Trace Commons 账户。请检查注册状态后重试。", "traceCommons.submittedTracesTitle": "已提交的跟踪记录", "traceCommons.tracesLoadFailed": "无法加载您已提交的跟踪记录。", "traceCommons.traceStatus": "状态", diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts new file mode 100644 index 00000000000..1a97fdbc5bc --- /dev/null +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vitest"; + +import { + formatCredit, + formatTimestamp, + openAccountLoginLink, + tracesSectionMode, +} from "./trace-commons-tab"; + +const TRACE = { submission_id: "s1", status: "accepted" }; + +describe("tracesSectionMode", () => { + test("traces load error always surfaces, never hides behind an empty state", () => { + expect(tracesSectionMode({ isError: true, enrolled: true, traces: [TRACE] })).toBe("error"); + // Error wins even for unenrolled/empty views — a backend failure must not + // render as "no traces". + expect(tracesSectionMode({ isError: true, enrolled: false, traces: [] })).toBe("error"); + }); + + test("enrolled contributor with traces renders the trace list", () => { + expect(tracesSectionMode({ isError: false, enrolled: true, traces: [TRACE] })).toBe("list"); + }); + + test("section hides for empty or unenrolled states", () => { + expect(tracesSectionMode({ isError: false, enrolled: true, traces: [] })).toBe("hidden"); + expect(tracesSectionMode({ isError: false, enrolled: false, traces: [TRACE] })).toBe("hidden"); + expect(tracesSectionMode({ isError: false, enrolled: false, traces: undefined })).toBe( + "hidden" + ); + }); +}); + +test("credit and timestamp formatting used by trace rows", () => { + expect(formatCredit(1)).toBe("1.00"); + expect(formatCredit("2.5")).toBe("2.50"); + expect(formatCredit(null)).toBe("0.00"); + expect(formatCredit("not-a-number")).toBe("0.00"); + + const t = (key: string) => key; + expect(formatTimestamp(null, t)).toBe("traceCommons.never"); + expect(formatTimestamp("garbage", t)).toBe("traceCommons.never"); + expect(formatTimestamp("2026-06-25T00:00:00Z", t)).not.toBe("traceCommons.never"); +}); + +// Fake window.open that captures EVERY argument the production caller passes +// (url, target, features) — a partial mock would hide a caller that drops the +// noopener hardening — plus the navigations assigned to the opened handle. +function fakeOpener() { + const calls: Array<{ url: string; target: string; features: string }> = []; + const windows: Array<{ closed: boolean; location: string | null; close: () => void }> = []; + return { + calls, + windows, + open: (url: string, target: string, features: string) => { + calls.push({ url, target, features }); + const win = { + closed: false, + location: null as string | null, + close() { + this.closed = true; + }, + }; + windows.push(win); + return win; + }, + }; +} + +describe("openAccountLoginLink", () => { + test("opens a blank tab synchronously, then navigates it to the minted URL", async () => { + const opener = fakeOpener(); + const result = await openAccountLoginLink({ + mint: async () => ({ + minted: true, + enrolled: true, + url: "https://commons.example/a?code=1", + }), + open: opener.open, + }); + + expect(result.status).toBe("opened"); + // The tab must be opened BEFORE the async mint resolves (popup-blocker + // attribution) with the full hardened argument set. + expect(opener.calls).toEqual([ + { url: "about:blank", target: "_blank", features: "noopener,noreferrer" }, + ]); + expect(opener.windows[0].location).toBe("https://commons.example/a?code=1"); + expect(opener.windows[0].closed).toBe(false); + }); + + test("unenrolled mint closes the placeholder tab and reports unavailable", async () => { + const opener = fakeOpener(); + const result = await openAccountLoginLink({ + mint: async () => ({ minted: false, enrolled: false }), + open: opener.open, + }); + expect(result.status).toBe("unavailable"); + expect(opener.windows[0].closed).toBe(true); + expect(opener.windows[0].location).toBeNull(); + }); + + test("mint failure closes the placeholder tab and reports error", async () => { + const opener = fakeOpener(); + const result = await openAccountLoginLink({ + mint: async () => { + throw new Error("500"); + }, + open: opener.open, + }); + expect(result.status).toBe("error"); + expect(opener.windows[0].closed).toBe(true); + }); + + test("popup-blocked open reports blocked without navigating", async () => { + const result = await openAccountLoginLink({ + mint: async () => ({ minted: true, enrolled: true, url: "https://commons.example/a" }), + open: () => null, + }); + expect(result.status).toBe("blocked"); + }); +}); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx index 4f5bbcb9908..8e19bfccf69 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx @@ -1,7 +1,10 @@ +import { useState } from "react"; + import { Card } from "../../../design-system/card"; import { useT } from "../../../lib/i18n"; import { useTraceCredits } from "../hooks/useTraceCredits"; import { useAccountTraces } from "../hooks/useAccountTraces"; +import { mintAccountLoginLink } from "../lib/settings-api"; import { matchesSearch } from "../lib/settings-search"; import { SettingsSearchEmpty } from "./settings-search-empty"; @@ -20,6 +23,29 @@ export function formatTimestamp(value, t) { return Number.isNaN(parsed.getTime()) ? t("traceCommons.never") : parsed.toLocaleString(); } +// Open the caller's Trace Commons account in a new tab via a one-time login +// link. Pure/injectable so tests can drive it without a browser: `mint` is the +// API call, `open` is the window.open-shaped opener. Opens a blank tab +// SYNCHRONOUSLY (before the async mint) so popup blockers attribute it to the +// user's click, then navigates it to the minted URL. The URL exists only in +// this flow — never logged, never stored. +export async function openAccountLoginLink({ mint, open }) { + const win = open("about:blank", "_blank", "noopener,noreferrer"); + try { + const response = await mint(); + if (!response || response.minted !== true || !response.url) { + if (win) win.close(); + return { status: "unavailable" }; + } + if (!win) return { status: "blocked" }; + win.location = response.url; + return { status: "opened" }; + } catch (error) { + if (win) win.close(); + return { status: "error", error }; + } +} + // Decide how the submitted-traces section renders. Pure so the branch logic is // unit-testable: "error" wins over everything (a tracesQuery failure must // surface, never silently hide behind an empty state), "list" needs an @@ -49,6 +75,16 @@ export function TraceCommonsTab({ searchQuery = "" }) { const t = useT(); const { credits, query, authorize } = useTraceCredits(); const { traces, enrolled: tracesEnrolled, query: tracesQuery } = useAccountTraces(); + const [openState, setOpenState] = useState("idle"); + + const handleOpenAccount = async () => { + setOpenState("pending"); + const result = await openAccountLoginLink({ + mint: mintAccountLoginLink, + open: (url, target, features) => window.open(url, target, features), + }); + setOpenState(result.status === "opened" ? "idle" : "failed"); + }; if ( !matchesSearch(searchQuery, [ @@ -279,6 +315,26 @@ export function TraceCommonsTab({ searchQuery = "" }) { {t("traceCommons.description")}

+ {(credits?.enrolled || tracesEnrolled) && ( +
+ + {openState === "failed" && ( + + {t("traceCommons.openAccountFailed")} + + )} +
+ )} + {body} {tracesSection} diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/lib/settings-api.ts b/crates/ironclaw_webui_v2/frontend/src/pages/settings/lib/settings-api.ts index 4c92728e6b0..318d735445e 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/lib/settings-api.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/lib/settings-api.ts @@ -226,6 +226,13 @@ export function fetchTraceCredits() { export function fetchAccountTraces() { return apiFetch("/api/webchat/v2/traces/account"); } +// Mint a one-time Trace Commons browser login link for the authenticated +// caller. The returned URL is a single-use account credential delivered only +// over this authenticated response — open it immediately, never log or store +// it. Unenrolled callers get { minted: false, enrolled: false }. +export function mintAccountLoginLink() { + return apiFetch("/api/webchat/v2/traces/account-login-link", { method: "POST" }); +} // Authorize a held (manual-review) trace for submission. No request body — // the submission id is in the path. Returns { authorized: bool }. export function authorizeTraceHold(submissionId) { diff --git a/crates/ironclaw_webui_v2/src/descriptors.rs b/crates/ironclaw_webui_v2/src/descriptors.rs index 010553d5118..4ebb3b401c6 100644 --- a/crates/ironclaw_webui_v2/src/descriptors.rs +++ b/crates/ironclaw_webui_v2/src/descriptors.rs @@ -40,6 +40,7 @@ pub const WEBUI_V2_ROUTE_DELETE_AUTOMATION: &str = "webui.v2.delete_automation"; pub const WEBUI_V2_ROUTE_TRACE_CREDITS: &str = "webui.v2.trace_credits"; pub const WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES: &str = "webui.v2.trace_account_traces"; pub const WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE: &str = "webui.v2.authorize_trace_hold"; +pub const WEBUI_V2_ROUTE_TRACE_ACCOUNT_LOGIN_LINK: &str = "webui.v2.trace_account_login_link"; pub const WEBUI_V2_ROUTE_GET_OUTBOUND_PREFERENCES: &str = "webui.v2.get_outbound_preferences"; pub const WEBUI_V2_ROUTE_SET_OUTBOUND_PREFERENCES: &str = "webui.v2.set_outbound_preferences"; pub const WEBUI_V2_ROUTE_LIST_OUTBOUND_DELIVERY_TARGETS: &str = @@ -136,6 +137,8 @@ pub const WEBUI_V2_PATTERN_TRACE_CREDITS: &str = "/api/webchat/v2/traces/credit" pub const WEBUI_V2_PATTERN_TRACE_ACCOUNT_TRACES: &str = "/api/webchat/v2/traces/account"; pub const WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE: &str = "/api/webchat/v2/traces/holds/{submission_id}/authorize"; +pub const WEBUI_V2_PATTERN_TRACE_ACCOUNT_LOGIN_LINK: &str = + "/api/webchat/v2/traces/account-login-link"; pub const WEBUI_V2_PATTERN_OUTBOUND_PREFERENCES: &str = "/api/webchat/v2/outbound/preferences"; pub const WEBUI_V2_PATTERN_OUTBOUND_DELIVERY_TARGETS: &str = "/api/webchat/v2/outbound/targets"; pub const WEBUI_V2_PATTERN_ADMIN_USERS: &str = "/api/webchat/v2/admin/users"; @@ -229,6 +232,7 @@ pub fn webui_v2_routes() -> Vec { delete_automation_descriptor(), trace_credits_descriptor(), trace_account_traces_descriptor(), + trace_account_login_link_descriptor(), authorize_trace_hold_descriptor(), get_outbound_preferences_descriptor(), set_outbound_preferences_descriptor(), @@ -950,6 +954,23 @@ fn authorize_trace_hold_descriptor() -> IngressRouteDescriptor { ) } +fn trace_account_login_link_descriptor() -> IngressRouteDescriptor { + descriptor( + WEBUI_V2_ROUTE_TRACE_ACCOUNT_LOGIN_LINK, + NetworkMethod::Post, + WEBUI_V2_PATTERN_TRACE_ACCOUNT_LOGIN_LINK, + mutation_policy( + // Caller-scoped mint; no request body. + BodyLimitPolicy::NoBody, + // Deliberately tighter than the standard mutation limit: each + // call mints a one-time account-access credential server-side. + rate_limit_per_caller(10, 60), + AuditTraceClass::UserAction, + AllowedEffectPath::ProductWorkflow, + ), + ) +} + fn get_outbound_preferences_descriptor() -> IngressRouteDescriptor { descriptor( WEBUI_V2_ROUTE_GET_OUTBOUND_PREFERENCES, diff --git a/crates/ironclaw_webui_v2/src/handlers.rs b/crates/ironclaw_webui_v2/src/handlers.rs index 87bc7f348b6..b970b95e5cc 100644 --- a/crates/ironclaw_webui_v2/src/handlers.rs +++ b/crates/ironclaw_webui_v2/src/handlers.rs @@ -27,40 +27,41 @@ use ironclaw_product_workflow::{ CodexLoginStart, FsMount, LifecyclePackageKind, LifecyclePackageRef, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductWorkflowError, ProjectFsFile, - ProjectionCursor, RebornAccountTracesResponse, RebornAddMemberRequest, - RebornAdminCreateUserRequest, RebornAdminPutSecretRequest, RebornAdminSecretDeletedResponse, - RebornAdminSecretResponse, RebornAdminSetRoleRequest, RebornAdminSetStatusRequest, - RebornAdminUpdateUserRequest, RebornAdminUserCreatedResponse, RebornAdminUserDeletedResponse, - RebornAdminUserListQuery, RebornAdminUserListResponse, RebornAdminUserResponse, - RebornAdminUserSecretsListResponse, RebornAttachmentRequest, RebornAutomationMutationResponse, - RebornCancelRunResponse, RebornConnectableChannelListResponse, RebornCreateProjectRequest, - RebornCreateThreadResponse, RebornDeleteProjectRequest, RebornDeleteThreadRequest, - RebornDeleteThreadResponse, RebornExtensionActionResponse, RebornExtensionListResponse, - RebornExtensionRegistryResponse, RebornFsListRequest, RebornFsListResponse, - RebornFsMountsResponse, RebornFsReadRequest, RebornFsStatRequest, RebornFsStatResponse, - RebornGetProjectRequest, RebornListAutomationsResponse, RebornListMembersRequest, - RebornListMembersResponse, RebornListProjectsRequest, RebornListProjectsResponse, - RebornListThreadsResponse, RebornLogQueryRequest, RebornLogQueryResponse, - RebornOperatorCommandPlaneResponse, RebornOperatorConfigGetResponse, - RebornOperatorConfigListResponse, RebornOperatorConfigSetRequest, - RebornOperatorConfigValidateRequest, RebornOperatorConfigValidateResponse, - RebornOperatorLogsQuery, RebornOperatorServiceLifecycleRequest, RebornOperatorSetupRequest, - RebornOperatorSetupResponse, RebornOutboundDeliveryTargetListResponse, - RebornOutboundPreferencesResponse, RebornProjectFsListRequest, RebornProjectFsListResponse, - RebornProjectFsReadRequest, RebornProjectFsStatRequest, RebornProjectFsStatResponse, - RebornProjectMemberInfo, RebornProjectResponse, RebornRemoveMemberRequest, - RebornResolveGateResponse, RebornRetryRunResponse, RebornServicesApi, RebornServicesError, - RebornServicesErrorCode, RebornServicesErrorKind, RebornSetOutboundPreferencesRequest, - RebornSetupExtensionResponse, RebornSkillActionResponse, RebornSkillContentResponse, - RebornSkillListResponse, RebornSkillSearchResponse, RebornStreamEventsRequest, - RebornSubmitTurnResponse, RebornTimelineRequest, RebornTimelineResponse, - RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse, RebornUpdateMemberRoleRequest, - RebornUpdateProjectRequest, SetActiveLlmRequest, SettingsToolPermissionState, - UpsertLlmProviderRequest, WebUiAttachmentCapabilities, WebUiAuthenticatedCaller, - WebUiCancelRunRequest, WebUiCreateThreadRequest, WebUiInboundValidationCode, - WebUiInboundValidationError, WebUiListAutomationsRequest, WebUiListThreadsRequest, - WebUiRenameAutomationRequest, WebUiResolveGateRequest, WebUiRetryRunRequest, - WebUiSendMessageRequest, WebUiSetupExtensionRequest, webui_attachment_capabilities, + ProjectionCursor, RebornAccountLoginLinkResponse, RebornAccountTracesResponse, + RebornAddMemberRequest, RebornAdminCreateUserRequest, RebornAdminPutSecretRequest, + RebornAdminSecretDeletedResponse, RebornAdminSecretResponse, RebornAdminSetRoleRequest, + RebornAdminSetStatusRequest, RebornAdminUpdateUserRequest, RebornAdminUserCreatedResponse, + RebornAdminUserDeletedResponse, RebornAdminUserListQuery, RebornAdminUserListResponse, + RebornAdminUserResponse, RebornAdminUserSecretsListResponse, RebornAttachmentRequest, + RebornAutomationMutationResponse, RebornCancelRunResponse, + RebornConnectableChannelListResponse, RebornCreateProjectRequest, RebornCreateThreadResponse, + RebornDeleteProjectRequest, RebornDeleteThreadRequest, RebornDeleteThreadResponse, + RebornExtensionActionResponse, RebornExtensionListResponse, RebornExtensionRegistryResponse, + RebornFsListRequest, RebornFsListResponse, RebornFsMountsResponse, RebornFsReadRequest, + RebornFsStatRequest, RebornFsStatResponse, RebornGetProjectRequest, + RebornListAutomationsResponse, RebornListMembersRequest, RebornListMembersResponse, + RebornListProjectsRequest, RebornListProjectsResponse, RebornListThreadsResponse, + RebornLogQueryRequest, RebornLogQueryResponse, RebornOperatorCommandPlaneResponse, + RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, + RebornOperatorConfigSetRequest, RebornOperatorConfigValidateRequest, + RebornOperatorConfigValidateResponse, RebornOperatorLogsQuery, + RebornOperatorServiceLifecycleRequest, RebornOperatorSetupRequest, RebornOperatorSetupResponse, + RebornOutboundDeliveryTargetListResponse, RebornOutboundPreferencesResponse, + RebornProjectFsListRequest, RebornProjectFsListResponse, RebornProjectFsReadRequest, + RebornProjectFsStatRequest, RebornProjectFsStatResponse, RebornProjectMemberInfo, + RebornProjectResponse, RebornRemoveMemberRequest, RebornResolveGateResponse, + RebornRetryRunResponse, RebornServicesApi, RebornServicesError, RebornServicesErrorCode, + RebornServicesErrorKind, RebornSetOutboundPreferencesRequest, RebornSetupExtensionResponse, + RebornSkillActionResponse, RebornSkillContentResponse, RebornSkillListResponse, + RebornSkillSearchResponse, RebornStreamEventsRequest, RebornSubmitTurnResponse, + RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, + RebornTraceHoldAuthorizeResponse, RebornUpdateMemberRoleRequest, RebornUpdateProjectRequest, + SetActiveLlmRequest, SettingsToolPermissionState, UpsertLlmProviderRequest, + WebUiAttachmentCapabilities, WebUiAuthenticatedCaller, WebUiCancelRunRequest, + WebUiCreateThreadRequest, WebUiInboundValidationCode, WebUiInboundValidationError, + WebUiListAutomationsRequest, WebUiListThreadsRequest, WebUiRenameAutomationRequest, + WebUiResolveGateRequest, WebUiRetryRunRequest, WebUiSendMessageRequest, + WebUiSetupExtensionRequest, webui_attachment_capabilities, }; use serde::{Deserialize, Serialize}; @@ -1294,6 +1295,21 @@ pub async fn trace_account_traces( Ok(Json(response)) } +/// `POST /api/webchat/v2/traces/account-login-link` +/// +/// Mint a one-time Trace Commons browser login link for the authenticated +/// caller (hosted users have no host-file access; this response is the only +/// delivery channel). Scope is derived from the caller; no input is accepted. +/// Unenrolled callers receive the zero-state, not an error. SECURITY: the +/// returned URL is a one-time account credential — it must never be logged. +pub async fn trace_account_login_link( + State(state): State, + Extension(caller): Extension, +) -> Result, WebUiV2HttpError> { + let response = state.services().trace_account_login_link(caller).await?; + Ok(Json(response)) +} + /// `POST /api/webchat/v2/traces/holds/{submission_id}/authorize` /// /// Authorize a held manual-review trace for submission (promote-as-is). The diff --git a/crates/ironclaw_webui_v2/src/lib.rs b/crates/ironclaw_webui_v2/src/lib.rs index 0b8e5043e5b..1041befc304 100644 --- a/crates/ironclaw_webui_v2/src/lib.rs +++ b/crates/ironclaw_webui_v2/src/lib.rs @@ -101,11 +101,11 @@ pub use descriptors::{ WEBUI_V2_ROUTE_START_CODEX_LOGIN, WEBUI_V2_ROUTE_START_NEARAI_LOGIN, WEBUI_V2_ROUTE_STAT_FS_PATH, WEBUI_V2_ROUTE_STAT_PROJECT_FILE, WEBUI_V2_ROUTE_STREAM_EVENTS, WEBUI_V2_ROUTE_STREAM_EVENTS_WS, WEBUI_V2_ROUTE_TEST_LLM_CONNECTION, - WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, WEBUI_V2_ROUTE_TRACE_CREDITS, - WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_PROJECT, - WEBUI_V2_ROUTE_UPDATE_PROJECT_MEMBER, WEBUI_V2_ROUTE_UPDATE_SKILL, - WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, is_webui_v2_operator_webui_config_route_id, - webui_v2_routes, + WEBUI_V2_ROUTE_TRACE_ACCOUNT_LOGIN_LINK, WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, + WEBUI_V2_ROUTE_TRACE_CREDITS, WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, + WEBUI_V2_ROUTE_UPDATE_PROJECT, WEBUI_V2_ROUTE_UPDATE_PROJECT_MEMBER, + WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, + is_webui_v2_operator_webui_config_route_id, webui_v2_routes, }; #[cfg(feature = "webui-v2-beta")] pub use error::{WebUiV2HttpError, WebUiV2HttpErrorBody}; diff --git a/crates/ironclaw_webui_v2/src/router.rs b/crates/ironclaw_webui_v2/src/router.rs index 6d26bed6fd6..40871f42f7a 100644 --- a/crates/ironclaw_webui_v2/src/router.rs +++ b/crates/ironclaw_webui_v2/src/router.rs @@ -45,8 +45,9 @@ use crate::descriptors::{ WEBUI_V2_PATTERN_START_CODEX_LOGIN, WEBUI_V2_PATTERN_START_NEARAI_LOGIN, WEBUI_V2_PATTERN_STAT_FS_PATH, WEBUI_V2_PATTERN_STAT_PROJECT_FILE, WEBUI_V2_PATTERN_STREAM_EVENTS, WEBUI_V2_PATTERN_STREAM_EVENTS_WS, - WEBUI_V2_PATTERN_TEST_LLM_CONNECTION, WEBUI_V2_PATTERN_TRACE_ACCOUNT_TRACES, - WEBUI_V2_PATTERN_TRACE_CREDITS, WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, + WEBUI_V2_PATTERN_TEST_LLM_CONNECTION, WEBUI_V2_PATTERN_TRACE_ACCOUNT_LOGIN_LINK, + WEBUI_V2_PATTERN_TRACE_ACCOUNT_TRACES, WEBUI_V2_PATTERN_TRACE_CREDITS, + WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, }; use crate::handlers; use crate::sse_capacity::SseCapacity; @@ -267,6 +268,10 @@ pub fn webui_v2_router_with_options(state: WebUiV2State, options: WebUiV2RouteOp WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, post(handlers::authorize_trace_hold), ) + .route( + WEBUI_V2_PATTERN_TRACE_ACCOUNT_LOGIN_LINK, + post(handlers::trace_account_login_link), + ) .route( WEBUI_V2_PATTERN_OUTBOUND_PREFERENCES, get(handlers::get_outbound_preferences).post(handlers::set_outbound_preferences), diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs index b7257efc356..d10440bb0c8 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs @@ -56,10 +56,10 @@ use ironclaw_webui_v2::{ WEBUI_V2_ROUTE_START_CODEX_LOGIN, WEBUI_V2_ROUTE_START_NEARAI_LOGIN, WEBUI_V2_ROUTE_STAT_FS_PATH, WEBUI_V2_ROUTE_STAT_PROJECT_FILE, WEBUI_V2_ROUTE_STREAM_EVENTS, WEBUI_V2_ROUTE_STREAM_EVENTS_WS, WEBUI_V2_ROUTE_TEST_LLM_CONNECTION, - WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, WEBUI_V2_ROUTE_TRACE_CREDITS, - WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_PROJECT, - WEBUI_V2_ROUTE_UPDATE_PROJECT_MEMBER, WEBUI_V2_ROUTE_UPDATE_SKILL, - WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, + WEBUI_V2_ROUTE_TRACE_ACCOUNT_LOGIN_LINK, WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, + WEBUI_V2_ROUTE_TRACE_CREDITS, WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, + WEBUI_V2_ROUTE_UPDATE_PROJECT, WEBUI_V2_ROUTE_UPDATE_PROJECT_MEMBER, + WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, }; /// Expected policy surface for one route. Everything host composition @@ -431,6 +431,23 @@ fn expected_table() -> Vec { audit: AuditTraceClass::UserAction, effect_path: AllowedEffectPath::ProductWorkflow, }, + Expected { + route_id: WEBUI_V2_ROUTE_TRACE_ACCOUNT_LOGIN_LINK, + method: NetworkMethod::Post, + pattern: "/api/webchat/v2/traces/account-login-link", + listener_class: ListenerClass::LocalGateway, + auth_schemes: &[IngressAuthScheme::BearerToken], + scope_source: IngressScopeSource::AuthenticatedCaller, + body_limit: BodyLimitPolicy::NoBody, + rate_limit_max: 10, + rate_limit_window_seconds: 60, + rate_limit_scope: RateLimitScope::PerCaller, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::UserAction, + effect_path: AllowedEffectPath::ProductWorkflow, + }, Expected { route_id: WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, method: NetworkMethod::Post, diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index b859ee91733..8031b7e3e3f 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -29,21 +29,21 @@ use ironclaw_product_adapters::{ use ironclaw_product_workflow::{ FsMount, LifecyclePackageRef, LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, LlmProviderView, ProjectFsEntry, - ProjectFsEntryKind, ProjectFsFile, ProjectFsStat, RebornAccountTracesResponse, - RebornAddMemberRequest, RebornAttachmentBytes, RebornAttachmentRequest, RebornAutomationInfo, - RebornAutomationMutationResponse, RebornAutomationRecentRunInfo, - RebornAutomationRecentRunStatus, RebornAutomationSource, RebornAutomationState, - RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, - RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, - RebornDeleteProjectRequest, RebornDeleteThreadRequest, RebornDeleteThreadResponse, - RebornExtensionActionResponse, RebornExtensionListResponse, RebornExtensionRegistryResponse, - RebornFsListRequest, RebornFsListResponse, RebornFsMountInfo, RebornFsMountsResponse, - RebornFsReadRequest, RebornFsStatRequest, RebornFsStatResponse, RebornGetRunStateRequest, - RebornGetRunStateResponse, RebornListAutomationsResponse, RebornListThreadsResponse, - RebornLogQueryRequest, RebornLogQueryResponse, RebornOperatorArea, - RebornOperatorCommandPlaneResponse, RebornOperatorConfigDiagnostic, - RebornOperatorConfigDiagnosticSeverity, RebornOperatorConfigEntry, - RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, + ProjectFsEntryKind, ProjectFsFile, ProjectFsStat, RebornAccountLoginLinkResponse, + RebornAccountTracesResponse, RebornAddMemberRequest, RebornAttachmentBytes, + RebornAttachmentRequest, RebornAutomationInfo, RebornAutomationMutationResponse, + RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationSource, + RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, + RebornChannelConnectStrategy, RebornConnectableChannelInfo, + RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteProjectRequest, + RebornDeleteThreadRequest, RebornDeleteThreadResponse, RebornExtensionActionResponse, + RebornExtensionListResponse, RebornExtensionRegistryResponse, RebornFsListRequest, + RebornFsListResponse, RebornFsMountInfo, RebornFsMountsResponse, RebornFsReadRequest, + RebornFsStatRequest, RebornFsStatResponse, RebornGetRunStateRequest, RebornGetRunStateResponse, + RebornListAutomationsResponse, RebornListThreadsResponse, RebornLogQueryRequest, + RebornLogQueryResponse, RebornOperatorArea, RebornOperatorCommandPlaneResponse, + RebornOperatorConfigDiagnostic, RebornOperatorConfigDiagnosticSeverity, + RebornOperatorConfigEntry, RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, RebornOperatorConfigSetRequest, RebornOperatorConfigValidateRequest, RebornOperatorConfigValidateResponse, RebornOperatorLogsQuery, RebornOperatorServiceLifecycleAction, RebornOperatorServiceLifecycleRequest, @@ -264,6 +264,8 @@ struct StubServices { /// `trace_account_traces` call, so the contract test can assert the handler /// forwards the caller (the route is caller-scoped). trace_account_traces_callers: Mutex>, + /// Forwarded caller user-ids for each `trace_account_login_link` call. + trace_account_login_link_callers: Mutex>, next_list_automations_error: Mutex>, next_rename_automation_error: Mutex>, next_delete_automation_error: Mutex>, @@ -1427,6 +1429,24 @@ impl RebornServicesApi for StubServices { traces: vec![], }) } + + async fn trace_account_login_link( + &self, + caller: WebUiAuthenticatedCaller, + ) -> Result { + // Capture the forwarded caller so the contract test can verify the + // caller-scoped route threads the authenticated identity, then return + // a hermetic canned mint (no network / filesystem). + self.trace_account_login_link_callers + .lock() + .expect("lock") + .push(caller.actor().user_id.as_str().to_string()); + Ok(RebornAccountLoginLinkResponse { + minted: true, + enrolled: true, + url: Some("https://commons.example/account/login?code=stub".to_string()), + }) + } } fn operator_command_response(area: RebornOperatorArea) -> RebornOperatorCommandPlaneResponse { @@ -2559,6 +2579,46 @@ async fn trace_account_traces_returns_caller_scoped_unenrolled_zero_state() { ); } +#[tokio::test] +async fn trace_account_login_link_returns_minted_url_to_caller_scope() { + // POST /traces/account-login-link is caller-scoped and returns the minted + // one-time URL in the authenticated response body — the only delivery + // channel hosted users have (no host-file access). + let services = Arc::new(StubServices::default()); + let router = router_with(services.clone()); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/webchat/v2/traces/account-login-link") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::OK); + let body = read_json(response).await; + assert_eq!(body["minted"], true); + assert_eq!(body["enrolled"], true); + assert_eq!( + body["url"], + "https://commons.example/account/login?code=stub" + ); + + // The route must forward the authenticated caller's user id to the + // facade (fails if it stops threading the caller). + assert_eq!( + services + .trace_account_login_link_callers + .lock() + .expect("lock") + .len(), + 1, + ); +} + #[tokio::test] async fn delete_automation_dispatches_path_id_to_facade() { let services = Arc::new(StubServices::default()); From ee60b3a5a6c7ae6e5db55c895b87ef7c2331369e Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 05:36:56 +0200 Subject: [PATCH 3/7] Address automated review: absolutize login-link URL, fix noopener handle, mint after popup check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mint_account_login_link_inner resolves a relative response url against the login-links endpoint (trust-anchored issuer origin); a relative url would otherwise resolve against the consuming surface's own origin (e.g. the IronClaw WebUI host). Tests assert the absolutized form on both paths. - openAccountLoginLink no longer passes the noopener feature to window.open — modern browsers return null for noopener opens, which made navigation impossible; the handle's opener is severed manually instead (reverse-tabnabbing protection preserved). The test fake now models the real browser contract (noopener feature → null handle). - Popup-blocked opens short-circuit BEFORE the mint call so a blocked tab no longer burns a single-use login link server-side; test asserts zero mints. - DirectPinnedContributionSink is pub(crate) with a documented invariant: request URLs must come from the enrolled policy's trust-anchored endpoint derivations, never caller input. Co-Authored-By: Claude Fable 5 --- .../src/contribution.rs | 38 ++++++++++++-- .../components/trace-commons-tab.test.ts | 50 +++++++++++++------ .../settings/components/trace-commons-tab.tsx | 28 +++++++---- 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 42319b19e8b..e5edbf0cd98 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -5561,7 +5561,12 @@ impl std::error::Error for ContributionHttpError {} /// no redirects, the request's own timeout, and a body read bounded DURING /// streaming by the request's `response_body_limit`. Agent-path callers must /// keep using the host-egress sink instead. -pub struct DirectPinnedContributionSink; +/// INVARIANT: request URLs handed to this sink must be derived from the +/// enrolled policy's trust-anchored endpoints (`account_login_links_url`, +/// `account_traces_url`, …) — never from caller/request input. The sink +/// attaches the caller's bearer to whatever URL it is given; keeping it +/// crate-private confines that to the vetted derivations in this module. +pub(crate) struct DirectPinnedContributionSink; #[async_trait] impl ContributionHttpSink for DirectPinnedContributionSink { @@ -6788,6 +6793,13 @@ async fn mint_account_login_link_inner( } let url = account_login_links_url(&resolution.policy) .map_err(AccountLoginLinkError::EnrollmentIncomplete)?; + // Parsed once up front: the join base for a relative `url` in the response + // (its origin is the trust-anchored issuer origin). + let endpoint_url = reqwest::Url::parse(&url).map_err(|e| { + AccountLoginLinkError::EnrollmentIncomplete( + anyhow::Error::new(e).context("login-links URL is not a valid URL"), + ) + })?; let context = TraceUploadClaimContext::for_account(resolution.subject.clone()).with_scope_dir(scope_dir); // Mint the bearer THROUGH the sink: on the agent path the upload-claim @@ -6842,6 +6854,23 @@ async fn mint_account_login_link_inner( AccountLoginLinkError::Backend(anyhow::anyhow!("login-link response missing url")) })? .to_string(); + // The server may return a relative path (e.g. `/account/login?code=…`). + // Resolve it against the login-links endpoint — whose origin is the + // trust-anchored issuer origin — so every delivery channel (browser + // navigation, local delivery file) receives an absolute URL instead of + // one that would resolve against the WRONG origin (e.g. the IronClaw + // WebUI's own host). + let link_url = match reqwest::Url::parse(&link_url) { + Ok(absolute) => absolute.to_string(), + Err(_) => endpoint_url + .join(&link_url) + .map_err(|e| { + AccountLoginLinkError::Backend( + anyhow::Error::new(e).context("login-link response url is not resolvable"), + ) + })? + .to_string(), + }; Ok(AccountLoginLink { account_id, url: link_url, @@ -16417,7 +16446,10 @@ mod tests { .unwrap(); // ── assertions ─────────────────────────────────────────────────────── - assert_eq!(link.url, "/account/login?code=abc"); + // The server returned a RELATIVE url; it must come back absolutized + // against the trust-anchored issuer origin, never left relative (a + // relative URL would resolve against the consuming surface's origin). + assert_eq!(link.url, format!("http://{addr}/account/login?code=abc")); assert_eq!(link.account_id, "11111111-1111-1111-1111-111111111111"); // Egress invariant: on the agent path BOTH network calls — the @@ -16463,7 +16495,7 @@ mod tests { let direct = mint_account_login_link_direct(base.path(), "tenant-dev", "alice") .await .expect("direct login-link mint succeeds"); - assert_eq!(direct.url, "/account/login?code=abc"); + assert_eq!(direct.url, format!("http://{addr}/account/login?code=abc")); assert_eq!(direct.account_id, "11111111-1111-1111-1111-111111111111"); let mut delivery_files = Vec::new(); let mut stack = vec![base.path().to_path_buf()]; diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts index 1a97fdbc5bc..e442f27af62 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts @@ -42,20 +42,34 @@ test("credit and timestamp formatting used by trace rows", () => { expect(formatTimestamp("2026-06-25T00:00:00Z", t)).not.toBe("traceCommons.never"); }); -// Fake window.open that captures EVERY argument the production caller passes -// (url, target, features) — a partial mock would hide a caller that drops the -// noopener hardening — plus the navigations assigned to the opened handle. +// Fake window.open that captures EVERY argument the production caller passes — +// a partial mock would hide a caller reintroducing the `noopener` feature +// (which makes window.open return null and breaks navigation) — plus the +// opener-severing and navigations applied to the opened handle. Mirrors the +// browser contract: passing a `noopener` feature yields a null handle. function fakeOpener() { - const calls: Array<{ url: string; target: string; features: string }> = []; - const windows: Array<{ closed: boolean; location: string | null; close: () => void }> = []; + const calls: Array<{ args: unknown[] }> = []; + const windows: Array<{ + closed: boolean; + location: string | null; + opener: unknown; + close: () => void; + }> = []; return { calls, windows, - open: (url: string, target: string, features: string) => { - calls.push({ url, target, features }); + open: (...args: unknown[]) => { + calls.push({ args }); + const features = String(args[2] ?? ""); + if (features.includes("noopener")) { + // Real browsers return null for noopener opens — the production + // caller must NOT pass it as a feature or it can never navigate. + return null; + } const win = { closed: false, location: null as string | null, + opener: "parent-window-sentinel" as unknown, close() { this.closed = true; }, @@ -67,7 +81,7 @@ function fakeOpener() { } describe("openAccountLoginLink", () => { - test("opens a blank tab synchronously, then navigates it to the minted URL", async () => { + test("opens a blank tab synchronously, severs opener, then navigates it", async () => { const opener = fakeOpener(); const result = await openAccountLoginLink({ mint: async () => ({ @@ -80,10 +94,11 @@ describe("openAccountLoginLink", () => { expect(result.status).toBe("opened"); // The tab must be opened BEFORE the async mint resolves (popup-blocker - // attribution) with the full hardened argument set. - expect(opener.calls).toEqual([ - { url: "about:blank", target: "_blank", features: "noopener,noreferrer" }, - ]); + // attribution), WITHOUT a `noopener` feature (which would null the handle + // and make navigation impossible). + expect(opener.calls).toEqual([{ args: ["about:blank", "_blank"] }]); + // Reverse-tabnabbing protection comes from severing the opener manually. + expect(opener.windows[0].opener).toBeNull(); expect(opener.windows[0].location).toBe("https://commons.example/a?code=1"); expect(opener.windows[0].closed).toBe(false); }); @@ -111,11 +126,18 @@ describe("openAccountLoginLink", () => { expect(opener.windows[0].closed).toBe(true); }); - test("popup-blocked open reports blocked without navigating", async () => { + test("popup-blocked open reports blocked WITHOUT burning a one-time link", async () => { + let minted = 0; const result = await openAccountLoginLink({ - mint: async () => ({ minted: true, enrolled: true, url: "https://commons.example/a" }), + mint: async () => { + minted += 1; + return { minted: true, enrolled: true, url: "https://commons.example/a" }; + }, open: () => null, }); expect(result.status).toBe("blocked"); + // Each mint burns a single-use credential server-side; a blocked popup + // must short-circuit BEFORE the mint call. + expect(minted).toBe(0); }); }); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx index 8e19bfccf69..34956184aab 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx @@ -25,23 +25,33 @@ export function formatTimestamp(value, t) { // Open the caller's Trace Commons account in a new tab via a one-time login // link. Pure/injectable so tests can drive it without a browser: `mint` is the -// API call, `open` is the window.open-shaped opener. Opens a blank tab -// SYNCHRONOUSLY (before the async mint) so popup blockers attribute it to the -// user's click, then navigates it to the minted URL. The URL exists only in -// this flow — never logged, never stored. +// API call, `open` is the window.open-shaped opener. +// +// Ordering is load-bearing: +// 1. Open a blank tab SYNCHRONOUSLY (before the async mint) so popup blockers +// attribute it to the user's click — WITHOUT the `noopener` feature, which +// would make window.open return null and leave nothing to navigate. +// Reverse-tabnabbing protection comes from severing `win.opener` manually. +// 2. Short-circuit on a blocked popup BEFORE minting: every mint burns a +// single-use login URL server-side. +// 3. Only then mint and navigate. The URL exists only in this flow — never +// logged, never stored. export async function openAccountLoginLink({ mint, open }) { - const win = open("about:blank", "_blank", "noopener,noreferrer"); + const win = open("about:blank", "_blank"); + if (!win) { + return { status: "blocked" }; + } + win.opener = null; try { const response = await mint(); if (!response || response.minted !== true || !response.url) { - if (win) win.close(); + win.close(); return { status: "unavailable" }; } - if (!win) return { status: "blocked" }; win.location = response.url; return { status: "opened" }; } catch (error) { - if (win) win.close(); + win.close(); return { status: "error", error }; } } @@ -81,7 +91,7 @@ export function TraceCommonsTab({ searchQuery = "" }) { setOpenState("pending"); const result = await openAccountLoginLink({ mint: mintAccountLoginLink, - open: (url, target, features) => window.open(url, target, features), + open: (url, target) => window.open(url, target), }); setOpenState(result.status === "opened" ? "idle" : "failed"); }; From f911ae9a1027fbc4960aa21ac099ff750a018a8b Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 05:54:09 +0200 Subject: [PATCH 4/7] Address local Codex review: pin login-link URL to issuer origin, sink transport tests, UI guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SECURITY: mint_account_login_link_inner now rejects any response url whose final resolved form leaves the trust-anchored issuer origin (scheme + host + port must match the login-links endpoint; userinfo rejected) — a hostile or compromised issuer response could otherwise steer the authenticated browser popup to an arbitrary origin or a javascript: URL. Tests cover relative absolutization, cross-origin rejection, javascript: rejection, and userinfo rejection. - DirectPinnedContributionSink transport hardening is now regression-tested: link-local/metadata hosts rejected at resolution, 3xx surfaced without contacting the Location target, oversized bodies rejected during the streamed read. - Frontend: handleOpenAccount gains an in-flight ref (double-click burns a one-time link) and a mounted ref (no setState after unmount). - Handler contract test now asserts the forwarded caller carries the authenticated tenant AND user id, not just call count. Co-Authored-By: Claude Fable 5 --- .../src/contribution.rs | 216 +++++++++++++++++- .../settings/components/trace-commons-tab.tsx | 20 +- .../tests/webui_v2_handlers_contract.rs | 22 +- 3 files changed, 237 insertions(+), 21 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index e5edbf0cd98..470e20f2cfe 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -5528,6 +5528,7 @@ pub struct ContributionHttpRequest { pub timeout_ms: u32, } +#[derive(Debug)] pub struct ContributionHttpResponse { pub status: u16, pub body: Vec, @@ -6860,20 +6861,31 @@ async fn mint_account_login_link_inner( // navigation, local delivery file) receives an absolute URL instead of // one that would resolve against the WRONG origin (e.g. the IronClaw // WebUI's own host). - let link_url = match reqwest::Url::parse(&link_url) { - Ok(absolute) => absolute.to_string(), - Err(_) => endpoint_url - .join(&link_url) - .map_err(|e| { - AccountLoginLinkError::Backend( - anyhow::Error::new(e).context("login-link response url is not resolvable"), - ) - })? - .to_string(), + let resolved = match reqwest::Url::parse(&link_url) { + Ok(absolute) => absolute, + Err(_) => endpoint_url.join(&link_url).map_err(|e| { + AccountLoginLinkError::Backend( + anyhow::Error::new(e).context("login-link response url is not resolvable"), + ) + })?, }; + // ORIGIN PIN: the caller navigates an authenticated user's browser to this + // URL. A hostile or compromised issuer response must not be able to steer + // that navigation anywhere else — the final URL must stay on the + // trust-anchored issuer origin (same scheme + host + port as the + // login-links endpoint; this also excludes non-HTTP(S) schemes such as + // `javascript:`) and must carry no userinfo. + let same_origin = resolved.scheme() == endpoint_url.scheme() + && resolved.host_str() == endpoint_url.host_str() + && resolved.port_or_known_default() == endpoint_url.port_or_known_default(); + if !same_origin || !resolved.username().is_empty() || resolved.password().is_some() { + return Err(AccountLoginLinkError::Backend(anyhow::anyhow!( + "login-link response url is not on the issuer origin" + ))); + } Ok(AccountLoginLink { account_id, - url: link_url, + url: resolved.to_string(), }) } @@ -16697,6 +16709,188 @@ mod tests { ); } + /// Helper: instance-enroll a tempdir against a mock whose + /// `/v1/account/login-links` returns `link_url`, then mint via the direct + /// path. Pins the origin-anchoring contract for hostile response URLs. + async fn mint_login_link_with_response_url( + link_url: &str, + ) -> (Result, String) { + let claim_jwt = test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "k1"})); + let claim_jwt_for_mock = claim_jwt.clone(); + let link_url = link_url.to_string(); + let app = axum::Router::new() + .route( + "/v1/trace-upload-claim", + axum::routing::post(move || { + let jwt = claim_jwt_for_mock.clone(); + async move { + axum::Json(serde_json::json!({ + "access_token": jwt, "token_type": "Bearer", "expires_in": 300 + })) + } + }), + ) + .route( + "/v1/account/login-links", + axum::routing::post(move || { + let url = link_url.clone(); + async move { + axum::Json(serde_json::json!({ + "account_id": "11111111-1111-1111-1111-111111111111", + "url": url + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let base = tempfile::tempdir().unwrap(); + enroll_instance_with_device_key(base.path(), addr); + let result = mint_account_login_link_direct(base.path(), "tenant-dev", "alice").await; + (result, format!("http://{addr}")) + } + + #[tokio::test] + async fn mint_account_login_link_pins_url_to_issuer_origin() { + // Relative url: absolutized against the trust-anchored issuer origin. + let (result, origin) = mint_login_link_with_response_url("/account/login?code=rel").await; + let link = result.expect("relative url absolutizes"); + assert_eq!(link.url, format!("{origin}/account/login?code=rel")); + + // Cross-origin ABSOLUTE url: a hostile issuer response must not steer + // the authenticated browser to another origin. + let (result, _) = + mint_login_link_with_response_url("https://attacker.example/account/login").await; + let error = result.expect_err("cross-origin absolute url must be rejected"); + assert!( + error.to_string().to_lowercase().contains("login link") + || matches!(error, AccountLoginLinkError::Backend(_)), + "cross-origin rejection surfaces as a Backend error: {error}" + ); + + // Non-HTTP(S) scheme: must be rejected (javascript: would execute in + // the opened tab's context). + let (result, _) = + mint_login_link_with_response_url("javascript:alert(document.domain)").await; + result.expect_err("non-http scheme must be rejected"); + + // Userinfo smuggling: rejected even when the host would match. (The + // mock's port isn't knowable before it binds, so a same-host+userinfo + // URL can't be fabricated exactly — but userinfo is rejected before + // the origin comparison, which cross-host coverage above pins anyway.) + let (result, _) = + mint_login_link_with_response_url("http://user:pass@127.0.0.1/account/login").await; + result.expect_err("userinfo in the login-link url must be rejected"); + } + + #[tokio::test] + async fn direct_pinned_sink_rejects_private_hosts_and_bounds_bodies() { + // Disallowed (link-local/metadata) host: rejected at resolution, before + // any request is built. + let error = DirectPinnedContributionSink + .execute(ContributionHttpRequest { + method: ContributionHttpMethod::Get, + url: "http://169.254.169.254/v1/anything".to_string(), + bearer_token: Some("secret".to_string()), + json_body: None, + response_body_limit: 1024, + timeout_ms: 2_000, + }) + .await + .expect_err("link-local host must be rejected"); + assert!( + error.to_string().contains("resolution rejected") + || error.to_string().contains("rejected"), + "rejection must come from host resolution: {error}" + ); + + // Redirects are NOT followed: the 3xx surfaces as the response status + // and the Location target is never contacted. + let hit = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let hit_for_target = hit.clone(); + let target_app = axum::Router::new().route( + "/stolen", + axum::routing::get(move || { + let hit = hit_for_target.clone(); + async move { + hit.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + "leaked" + } + }), + ); + let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_addr = target_listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(target_listener, target_app).await; + }); + let redirect_to = format!("http://{target_addr}/stolen"); + let redirect_app = axum::Router::new().route( + "/hop", + axum::routing::get(move || { + let location = redirect_to.clone(); + async move { + ( + axum::http::StatusCode::FOUND, + [(axum::http::header::LOCATION, location)], + "", + ) + } + }), + ); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let redirect_addr = redirect_listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(redirect_listener, redirect_app).await; + }); + let response = DirectPinnedContributionSink + .execute(ContributionHttpRequest { + method: ContributionHttpMethod::Get, + url: format!("http://{redirect_addr}/hop"), + bearer_token: Some("secret".to_string()), + json_body: None, + response_body_limit: 1024, + timeout_ms: 2_000, + }) + .await + .expect("redirect response surfaces, not followed"); + assert_eq!(response.status, 302, "3xx must surface as the status"); + assert_eq!( + hit.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the redirect target must never be contacted" + ); + + // Oversized body: rejected DURING the read, not buffered. + let big_app = axum::Router::new().route( + "/big", + axum::routing::get(|| async { "x".repeat(64 * 1024) }), + ); + let big_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let big_addr = big_listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(big_listener, big_app).await; + }); + let error = DirectPinnedContributionSink + .execute(ContributionHttpRequest { + method: ContributionHttpMethod::Get, + url: format!("http://{big_addr}/big"), + bearer_token: None, + json_body: None, + response_body_limit: 1024, + timeout_ms: 5_000, + }) + .await + .expect_err("oversized body must be rejected"); + assert!( + error.to_string().contains("exceeds"), + "rejection names the byte limit: {error}" + ); + } + #[tokio::test] async fn mint_account_login_link_errors_when_not_enrolled() { let base = tempfile::tempdir().unwrap(); diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx index 34956184aab..2d4677837d4 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Card } from "../../../design-system/card"; import { useT } from "../../../lib/i18n"; @@ -86,14 +86,30 @@ export function TraceCommonsTab({ searchQuery = "" }) { const { credits, query, authorize } = useTraceCredits(); const { traces, enrolled: tracesEnrolled, query: tracesQuery } = useAccountTraces(); const [openState, setOpenState] = useState("idle"); + // Imperative guards: `openInFlightRef` makes double-click protection + // independent of render timing (each extra click would burn a one-time + // link), and `mountedRef` prevents a state update landing after unmount. + const openInFlightRef = useRef(false); + const mountedRef = useRef(true); + useEffect( + () => () => { + mountedRef.current = false; + }, + [] + ); const handleOpenAccount = async () => { + if (openInFlightRef.current) return; + openInFlightRef.current = true; setOpenState("pending"); const result = await openAccountLoginLink({ mint: mintAccountLoginLink, open: (url, target) => window.open(url, target), }); - setOpenState(result.status === "opened" ? "idle" : "failed"); + openInFlightRef.current = false; + if (mountedRef.current) { + setOpenState(result.status === "opened" ? "idle" : "failed"); + } }; if ( diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index 8031b7e3e3f..5479c276252 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -1434,13 +1434,18 @@ impl RebornServicesApi for StubServices { &self, caller: WebUiAuthenticatedCaller, ) -> Result { - // Capture the forwarded caller so the contract test can verify the - // caller-scoped route threads the authenticated identity, then return - // a hermetic canned mint (no network / filesystem). + // Capture the forwarded caller (tenant AND user — this is the trusted + // identity boundary) so the contract test can verify the caller-scoped + // route threads the authenticated identity, then return a hermetic + // canned mint (no network / filesystem). self.trace_account_login_link_callers .lock() .expect("lock") - .push(caller.actor().user_id.as_str().to_string()); + .push(format!( + "{}/{}", + caller.tenant_id.as_str(), + caller.actor().user_id.as_str() + )); Ok(RebornAccountLoginLinkResponse { minted: true, enrolled: true, @@ -2607,15 +2612,16 @@ async fn trace_account_login_link_returns_minted_url_to_caller_scope() { "https://commons.example/account/login?code=stub" ); - // The route must forward the authenticated caller's user id to the - // facade (fails if it stops threading the caller). + // The route must forward the authenticated caller's tenant AND user id to + // the facade — the scope is trusted-caller-derived, never request input + // (fails if the handler stops threading the caller). assert_eq!( services .trace_account_login_link_callers .lock() .expect("lock") - .len(), - 1, + .clone(), + vec!["tenant-alpha/user-alpha".to_string()], ); } From c6466d662a86556d8ca64f10b2bb2e46b98b2ca0 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 05:55:23 +0200 Subject: [PATCH 5/7] Client-side defense in depth: refuse non-http(s) minted login URLs The about:blank tab inherits the WebUI origin, so a javascript: URL would execute with WebUI-origin access (e.g. session storage). The backend now origin-pins the minted URL; the client additionally refuses to navigate to anything that is not absolute http(s), independently. Co-Authored-By: Claude Fable 5 --- .../components/trace-commons-tab.test.ts | 21 +++++++++++++++++++ .../settings/components/trace-commons-tab.tsx | 17 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts index e442f27af62..65216b2c1bb 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { formatCredit, formatTimestamp, + isSafeLoginLinkUrl, openAccountLoginLink, tracesSectionMode, } from "./trace-commons-tab"; @@ -126,6 +127,26 @@ describe("openAccountLoginLink", () => { expect(opener.windows[0].closed).toBe(true); }); + test("non-web minted URLs are refused before navigation (defense in depth)", async () => { + // The about:blank tab inherits the WebUI origin — a javascript: URL would + // execute with WebUI-origin access. The backend origin-pins the URL, but + // the client refuses independently. + expect(isSafeLoginLinkUrl("https://commons.example/a")).toBe(true); + expect(isSafeLoginLinkUrl("http://127.0.0.1:8080/a")).toBe(true); + expect(isSafeLoginLinkUrl("javascript:alert(1)")).toBe(false); + expect(isSafeLoginLinkUrl("data:text/html,x")).toBe(false); + expect(isSafeLoginLinkUrl("/relative/path")).toBe(false); + + const opener = fakeOpener(); + const result = await openAccountLoginLink({ + mint: async () => ({ minted: true, enrolled: true, url: "javascript:alert(1)" }), + open: opener.open, + }); + expect(result.status).toBe("unavailable"); + expect(opener.windows[0].closed).toBe(true); + expect(opener.windows[0].location).toBeNull(); + }); + test("popup-blocked open reports blocked WITHOUT burning a one-time link", async () => { let minted = 0; const result = await openAccountLoginLink({ diff --git a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx index 2d4677837d4..d032dd8baf2 100644 --- a/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx +++ b/crates/ironclaw_webui_v2/frontend/src/pages/settings/components/trace-commons-tab.tsx @@ -36,6 +36,19 @@ export function formatTimestamp(value, t) { // single-use login URL server-side. // 3. Only then mint and navigate. The URL exists only in this flow — never // logged, never stored. +// 4. Defense in depth: even though the backend pins the minted URL to the +// trust-anchored issuer origin, refuse to navigate to anything that is +// not absolute http(s) — the about:blank tab inherits the WebUI origin, +// so a javascript: URL would execute with WebUI-origin access. +export function isSafeLoginLinkUrl(raw) { + try { + const parsed = new URL(raw); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + export async function openAccountLoginLink({ mint, open }) { const win = open("about:blank", "_blank"); if (!win) { @@ -48,6 +61,10 @@ export async function openAccountLoginLink({ mint, open }) { win.close(); return { status: "unavailable" }; } + if (!isSafeLoginLinkUrl(response.url)) { + win.close(); + return { status: "unavailable" }; + } win.location = response.url; return { status: "opened" }; } catch (error) { From b05692801e6adf14c8215fabd9a69fa92bb8b43d Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 06:32:24 +0200 Subject: [PATCH 6/7] Scoped-only user opt-out: never disable the instance enrollment for one user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on c6466d662 caught that 'traces opt-out' always flips the ROOT policy before the scoped one — and since #5280 that root file IS the instance-wide enrollment, so opting out one user (even with --user-scope) silently disenrolled the entire instance. The enroll-instance guidance text pointed users at exactly that command. - New primitive ironclaw_reborn_traces::contribution::opt_out_user_scope[_at]: writes ONLY the scoped policy with enabled=false (the resolver's explicit opt-out signal). Test pins: instance policy untouched on disk, the opted-out user stops resolving, other users keep inheriting. - CLI: --user-scope now performs a scoped-only opt-out; bare opt-out keeps the legacy full-disable semantics and prints a note distinguishing the two. enroll-instance guidance names the scoped form. Co-Authored-By: Claude Fable 5 --- .../src/commands/traces/mod.rs | 35 ++++++++--- .../src/contribution.rs | 59 +++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs b/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs index a7f1718069e..d2f584173b0 100644 --- a/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs +++ b/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs @@ -122,9 +122,12 @@ enum TracesSubcommand { min_submission_score: f32, }, - /// Disable autonomous trace contribution + /// Disable autonomous trace contribution. With --user-scope: opt out ONLY + /// that user (instance-level enrollment untouched). Without: disable the + /// global/instance policy AND the owner scope (full off switch). OptOut { - /// Runtime/web user scope to disable; defaults to this instance's owner_id + /// Runtime/web user scope to disable (scoped-only opt-out); defaults + /// to this instance's owner_id AND disables the global policy #[arg(long)] user_scope: Option, }, @@ -548,15 +551,32 @@ fn opt_in(options: OptInOptions) -> anyhow::Result<()> { } fn opt_out(user_scope: Option<&str>) -> anyhow::Result<()> { + let explicit_scope = user_scope.is_some(); let runtime_scope = trace_runtime_user_scope(user_scope)?; + if explicit_scope { + // Per-user opt-out: write ONLY the scoped policy. The root policy is + // the instance-wide enrollment (inherited by every user without a + // personal enrollment); flipping it here would disenroll the entire + // instance to opt out one user. + ironclaw_reborn_traces::contribution::opt_out_user_scope(&runtime_scope)?; + println!("Trace contribution disabled for user scope: {runtime_scope}"); + println!( + "The instance-level enrollment (if any) is unchanged; other users keep contributing." + ); + return Ok(()); + } + // No explicit scope: legacy full disable — the global/instance policy AND + // the owner's scope. This is the admin-facing "turn it all off" path. let mut policy = read_policy()?; policy.enabled = false; write_policy(&policy)?; - let mut scoped_policy = read_trace_policy_for_scope(Some(&runtime_scope))?; - scoped_policy.enabled = false; - write_trace_policy_for_scope(Some(&runtime_scope), &scoped_policy)?; + ironclaw_reborn_traces::contribution::opt_out_user_scope(&runtime_scope)?; println!("Trace contribution opt-in disabled. Queued envelopes remain local."); println!("Runtime/web trace scope disabled: {runtime_scope}"); + println!( + "NOTE: this also disabled the global/instance-level policy. To opt out a single \ + user instead, pass --user-scope /." + ); Ok(()) } @@ -614,8 +634,9 @@ async fn enroll_instance( enrollment, attributed via salted per-user pseudonyms." ); println!( - "Users can exclude themselves with `ironclaw-reborn traces opt-out`; an explicit \ - opt-out always wins over instance enrollment." + "Opt a single user out with `ironclaw-reborn traces opt-out --user-scope \ + /`; an explicit opt-out always wins over instance enrollment \ + (bare `traces opt-out` disables the whole instance enrollment)." ); println!("Verify with `ironclaw-reborn traces status --json` and `traces ingest-health`."); Ok(()) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 470e20f2cfe..382b02453d1 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -4263,6 +4263,24 @@ fn resolve_trace_credentials_at( Ok(None) } +/// Explicit per-user Trace Commons opt-out: write (or update) the scope's +/// policy with `enabled = false`, which the resolver treats as an explicit +/// opt-out that blocks the instance fallback for this user — WITHOUT touching +/// the instance-level (scope-`None`) policy. This is the primitive a per-user +/// opt-out surface must use: flipping the root policy would disenroll the +/// entire instance. +pub fn opt_out_user_scope_at(base: &std::path::Path, scope: &str) -> anyhow::Result<()> { + let mut policy = + read_trace_policy_for_scope_if_present_at(base, Some(scope))?.unwrap_or_default(); + policy.enabled = false; + write_trace_policy_for_scope_at(base, Some(scope), &policy) +} + +/// [`opt_out_user_scope_at`] against the process base dir. +pub fn opt_out_user_scope(scope: &str) -> anyhow::Result<()> { + opt_out_user_scope_at(&ironclaw_common::paths::ironclaw_base_dir(), scope) +} + /// Pick the user's own (personal-invite) enrollment when present and enabled, /// else fall back to the admin-provisioned instance enrollment (scope `None`) /// with a per-user pseudonymous subject. Returns `None` when neither is @@ -16060,6 +16078,47 @@ mod tests { ); } + #[test] + fn opt_out_user_scope_blocks_only_that_user_never_the_instance() { + // Regression (PR #5858 review): the CLI's opt-out used to flip the + // ROOT policy too — which, under instance enrollment, disenrolled the + // ENTIRE instance when one user opted out. The per-user opt-out + // primitive must write only the user's scoped policy. + let dir = tempfile::tempdir().unwrap(); + write_policy_at( + dir.path(), + None, + &StandingTraceContributionPolicy { + enabled: true, + ..Default::default() + }, + ); + + let alice = trace_scope_key("tenant-a", "alice"); + opt_out_user_scope_at(dir.path(), &alice).expect("opt-out writes"); + + // The instance policy is untouched on disk. + let instance = read_trace_policy_for_scope_at(dir.path(), None).expect("instance reads"); + assert!( + instance.enabled, + "per-user opt-out must never disable the instance enrollment" + ); + // Alice is out on every resolution surface… + assert!( + resolve_trace_credentials_at(dir.path(), "tenant-a", "alice") + .unwrap() + .is_none(), + "opted-out user must not resolve instance credentials" + ); + // …while other users still inherit the instance enrollment. + assert!( + resolve_trace_credentials_at(dir.path(), "tenant-a", "bob") + .unwrap() + .is_some(), + "other users must keep inheriting the instance enrollment" + ); + } + #[test] fn resolver_explicit_user_opt_out_blocks_instance_fallback() { // `traces opt-out` writes the user's scoped policy with enabled=false. From adffaa5e01199e8dca1d1378a8ba07f3286595b4 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 9 Jul 2026 06:49:14 +0200 Subject: [PATCH 7/7] Align enroll-instance clap help with scoped-only opt-out semantics The doc comment still pointed users at bare 'traces opt-out' for self-exclusion, which is now the instance-wide off switch; name the --user-scope form and warn about the bare form, matching the runtime output. Co-Authored-By: Claude Fable 5 --- crates/ironclaw_reborn_cli/src/commands/traces/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs b/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs index d2f584173b0..224f5487be0 100644 --- a/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs +++ b/crates/ironclaw_reborn_cli/src/commands/traces/mod.rs @@ -135,8 +135,9 @@ enum TracesSubcommand { /// Enroll this ENTIRE INSTANCE in Trace Commons with an operator invite /// link (admin operation — requires shell access to the instance host). /// Every user without a personal enrollment inherits it, attributed via a - /// salted per-user pseudonym; users can exclude themselves with - /// `traces opt-out`. + /// salted per-user pseudonym. Exclude a single user with + /// `traces opt-out --user-scope /` (bare + /// `traces opt-out` disables the entire instance enrollment). EnrollInstance { /// Operator invite link (`https://#`, or `@`) #[arg(long)]