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