From f2d13ba373c70cfce94e2d66c78d875398e44764 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 09:12:25 -0700 Subject: [PATCH 01/46] docs: spec for Trace Commons instance enrollment, profiles, and trace inspection Cross-repo design (ironclaw + trace-commons-server) for three coexisting capabilities: instance-wide enrollment, per-user contributor accounts via login-links, and submitted-trace inspection. Introduces a trace-credential resolver so the existing user-invite model and the new instance-wide model both function on one instance, with personal-invite enrollment taking precedence. Server change is additive (optional per-user subject through claim issuance + login-link + account resolution). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...e-enrollment-profiles-inspection-design.md | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md diff --git a/docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md b/docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md new file mode 100644 index 00000000000..69e2ad0f04f --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md @@ -0,0 +1,232 @@ +# Trace Commons: instance-wide enrollment, user profiles, and trace inspection + +Status: proposed (2026-06-25) +Repos: `ironclaw` (client) + `trace-commons-server` (server) +Server reference branch: `contributor-account-slice1` + +## Problem + +Trace Commons is gaining three capabilities that IronClaw must integrate: + +1. **Instance-wide enrollment** — an IronClaw deployment enrolls *once* and all + of its users contribute under that enrollment, instead of every user (scope) + redeeming an invite individually. +2. **User profiles / contributor accounts** — a human can hold a Trace Commons + contributor account (public handle/bio, credit balance, browser session) and + manage it from a web session minted by IronClaw. +3. **Trace inspection** — a user can read their *submitted* traces back from the + server (list / detail / scrubbed content), complementing IronClaw's existing + *local* held-trace review. + +The existing per-scope **user-invite** onboarding must keep working unchanged and +must coexist with the new instance-wide model on the same instance. + +## What already exists (reuse — do not rebuild) + +### IronClaw side +- **Onboarding wire contract already matches the server.** + `crates/ironclaw_reborn_traces/src/onboarding/protocol.rs` defines + `ONBOARD_REQUEST_SCHEMA_VERSION = "trace_commons.onboard_request.v1"` and the + response constant, identical to the server, with matching fields + (`invite_code`, `device_public_key`, `client_info`). +- **Per-scope onboarding flow** (`onboarding/mod.rs`): parses the invite (trust + root), generates a device keypair (`device_key.rs`), POSTs via an injectable + `OnboardingHttpSink`, anchors the issuer origin, and writes a + `StandingTraceContributionPolicy` to + `trace_contribution_dir_for_scope(Some(scope))/policy.json`. +- **Pseudonymization helpers** (`contribution.rs`): + `local_pseudonymous_contributor_id(scope)` and + `local_pseudonymous_tenant_scope_ref(scope)` turn a `"{tenant}:{user}"` scope + string into a stable opaque hash (`tenant_sha256:…`). This is the per-user + subject mechanism — it already exists. +- **Trace contribution pipeline**: capture → redact → queue → hold → submit, + plus credits, held-trace review, and `ContributionHttpSink` for host-routed + egress. Web handlers in `src/channels/web/handlers/traces.rs`. +- **First-party Trace Commons tools** (`crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs`): + `onboard`, `status`, `credits`, `profile_token`, `profile_set` — model-visible + capabilities routed through host egress + `ToolDispatcher`. + +### Admin / multi-tenant primitives +- `UserRole::{Owner, Admin, Regular}` with centralized `is_admin()` + (`src/ownership/mod.rs`). +- `AdminScope` (`src/tenant.rs`) — constructable only when `identity.is_admin()`; + the fail-closed typed gate for admin operations (currently user management). +- `DeploymentMode::HostedMultiTenant`, `Config::is_multi_tenant_deployment()`, + `TenantScope` compile-time isolation, per-tenant rate limiting. +- Secrets module (AES-256-GCM, OS keychain) for credential storage. + +### Server side (trace-commons-server) +- Device-key onboarding (`crates/trace-commons-protocol/src/onboarding.rs`, + migrations `V28__device_keys.sql`, `V29__onboarding_invites.sql`). +- Upload-claim issuer (`src/trace_upload_claim_issuer.rs`) — mints bearer claims; + for device keys the JWT subject is currently fixed to `device_key_id`. +- Trace submission `POST /v1/traces`; stores `auth_principal_ref` from the + bearer principal. +- Community profile (`PUT/DELETE /v1/community/profile`), credits + (`GET /v1/contributors/me/credit`, `…/credit-events`, + `POST /v1/contributors/me/submission-status`), public + `GET /v1/community/leaderboard|contributors/{handle}|analytics/summary`. +- Admin device-key + tenant-access-grant management + (`/v1/admin/device-keys`, `/v1/admin/tenant-access-grants`). +- **In progress (Slice 1, this branch):** contributor accounts — + `trace_accounts`, `trace_account_principals`, `trace_login_links`, + `trace_sessions` (planned `V30`), the `account_session.rs` module, and the + `/v1/account/*` endpoints (login-links, traces list/detail/content, logout, + revoke-all). Account is keyed `(tenant_id, principal_ref)`, `UNIQUE` per + principal, "one principal per account" in Slice 1. + +## Core decision: per-user identity under one instance device key + +The server resolves a contributor account and stamps `auth_principal_ref` +**from the bearer principal**, which for a device key is fixed to the +`device_key_id`. Therefore one shared instance device key would collapse all +users into one account/principal. Per-user separation under a shared key +requires a small, additive server change (authorized by the maintainer). + +The change runs in the **safe direction**: one instance device key *fanning out* +to many per-user accounts, all confined to the instance's own tenant by the +server's existing RLS. A compromised instance can only mis-attribute *within its +own tenant* — it can never reach another tenant. This is the trust boundary +IronClaw already owns (it authenticates its own users via `UserRole`/`TenantScope`). +This is distinct from — and far weaker than — the Slice 3 threat (linking many +principals *into* one account), which remains gated behind a strong authenticator. + +## Two coexisting models, one resolver + +Both onboarding models must function simultaneously on one instance. They differ +only in what a single new **trace-credential resolver** returns for a given +`(tenant, user)` / scope: + +| Model | Device key source | `auth_principal_ref` | Subject sent | Account | +|-------|-------------------|----------------------|--------------|---------| +| **User invite** (existing) | the user's own onboarded key (per-scope `policy.json`) | bare `device:{tenant}:{key}` | No | 1:1, exactly as today | +| **Instance-wide** (new) | shared instance key (instance-level policy) | `instance:{tenant}:{key}:user:{subject}` | Yes (pseudonymized) | per-user under instance | + +`subject = local_pseudonymous_contributor_id("{tenant}:{user_id}")` — opaque, +never the raw user id. + +Everything downstream — claim minting, submission, login-links, trace inspection +— flows through the same code; only the resolver output differs. + +**Precedence:** a user's own (personal-invite) enrollment wins over the instance +enrollment when both exist. A self-hoster who never sets up an instance +enrollment keeps working exactly as today; a managed-instance user who redeems a +personal invite can "bring their own" Trace Commons identity. + +## Server changes (additive, backward-compatible) + +All three make `subject` optional → absent reproduces today's behavior exactly. + +1. **`TraceUploadClaimRequest`** (`src/trace_upload_claim_issuer.rs`): add + `subject: Option` (`#[serde(default)]`). +2. **`issue_claim_for_device_key`**: when `subject` is present, validate/normalize + it (length, charset; it is already a `tenant_sha256:…`-style opaque token) and + set both the JWT `sub` and the derived + `auth_principal_ref = principal_storage_ref("instance:{tenant}:{device_key_id}:user:{subject}")`. + When absent, keep the current `device_key_id` behavior. +3. **`POST /v1/account/login-links`** + `create_or_reuse_account`: accept the same + optional `subject` so the resolved account principal matches the submitting + principal. Request body gains an optional `subject` field; account resolution + keys on the derived principal_ref, not the bare device principal. + +No new tables. `trace_accounts` / `trace_account_principals` already key on +`(tenant_id, principal_ref)`; we only change what `principal_ref` is. Subject +namespacing (`instance:{tenant}:{device_key_id}:user:{subject}`) guarantees +subjects cannot collide across instances/tenants. + +Server-side validation requirements: +- A device-key bearer may only request a `subject` claim for its own + tenant/device namespace (enforced by the derivation, not by trusting a + client-supplied prefix). +- Subject format validated against an explicit pattern; reject malformed. +- Audit (hash/label only) records subject-scoped mints distinctly from + device-only mints. + +## IronClaw changes + +### 1. Trace-credential resolver (new seam) +A single function (in `ironclaw_reborn_traces`, host-side) that, given the +authenticated user/scope, returns `{ device_key_id, tenant, ingest_url, +issuer_url, subject: Option }` by: +1. checking for a per-scope (personal-invite) `StandingTraceContributionPolicy` + → if present, use it with `subject = None` (current behavior); +2. else falling back to the instance-level policy with + `subject = Some(local_pseudonymous_contributor_id("{tenant}:{user}"))`. + +All existing call sites that mint claims / submit / build login-links route +through this resolver instead of reading a per-scope policy directly. + +### 2. Instance-wide enrollment (admin-gated, additive) +- New `AdminScope` method to perform + persist instance enrollment, reusing the + existing `onboarding/` flow (`onboard_at_dir_with_sink`) but writing to an + **instance-level** policy location (not `trace_contribution_dir_for_scope`). +- Device private key stored via the secrets module; tenant/ingest/issuer policy + in an instance-level `StandingTraceContributionPolicy`. +- Exposed through `ToolDispatcher::dispatch` (admin-gated) and a web settings + action. Non-admin users never onboard; they inherit via the resolver. +- The existing per-scope `trace_commons.onboard` (personal invite) is unchanged. + +### 3. Per-user subject plumbing +- Thread the resolver's `subject` into upload-claim requests + (`fetch_trace_upload_claim_from_issuer` / `ContributionHttpSink`) and + submission so per-user credits/attribution work under the instance key. +- `subject = None` path is byte-for-byte the current request. + +### 4. User profiles — login-link capability +- New first-party capability `trace_commons.account_login_link` (model-visible, + consent-gated like `profile_token`) → `POST /v1/account/login-links` with the + resolver's subject → returns the one-time browser URL, surfaced to the user via + their channel. This is how a user obtains a Trace Commons web session to manage + their profile. +- Optional read-through of public `/v1/community/*` for profile/leaderboard + display. + +### 5. Trace inspection +- New capabilities + `/api/webchat/v2/traces/...` handlers wrapping + `GET /v1/account/traces`, `/{submission_id}`, `/{submission_id}/content` + (scrubbed), authenticated with the instance device bearer + per-user subject. +- Surface the user's *submitted* traces (status, credit, scrubbed content) + alongside the existing *local* held-trace review and credit card. +- All mutations/reads via `ToolDispatcher::dispatch`; dual-backend persistence + for any local state (Postgres + libSQL). + +## Extension/auth invariants +- `credential_name` (backend secret identity) vs `extension_name` (user-facing) + must not be conflated; instance enrollment credentials live under the secrets + module keyed by a stable `credential_name`, with `extension_name = trace_commons` + for any setup/configure UI routing. +- New onboarding/auth code reuses the shared resolver/controller path — no + channel-specific or frontend-only fallbacks. + +## Sequencing (each slice independently shippable, TDD) + +0. **Server:** optional `subject` through claim issuance + login-link + account + resolution (additive; existing behavior unchanged when subject absent). +1. **IronClaw:** trace-credential resolver + admin-gated instance enrollment + (instance-level policy; per-scope flow untouched). +2. **IronClaw:** per-user subject plumbing through claim + submission → per-user + credits/attribution under the instance key. +3. **IronClaw:** `account_login_link` capability → user profiles / web session. +4. **IronClaw:** trace-inspection capabilities + web UI (list/detail/scrubbed + content) + optional public community read. + +## Testing +- **Resolver:** unit tests for both branches + precedence (personal invite wins). +- **Server (subject path):** absent-subject request is byte-identical to today; + present-subject yields a distinct `auth_principal_ref` and distinct account; + two subjects under one device key resolve to two accounts; cross-tenant subject + collision impossible. +- **Through the caller (per CLAUDE.md "Test Through the Caller"):** drive the + submission/login-link/inspection *handlers* (not just the resolver helper) at + the integration tier — claim mint, submission attribution, login-link account + resolution, and inspection ownership filtering all exercised end-to-end. +- **Coexistence:** on one instance, a personal-invite user and an + instance-inherited user both submit and inspect without cross-contamination. +- **Admin gate:** instance enrollment rejected for non-admin identities + (`AdminScope::new` returns `None`). + +## Out of scope (YAGNI) +- Multi-device / NEAR / passkey account linking (server Slice 3). +- Reviewer-role endpoints (`/v1/traces`, `/v1/review/*`) — operator-facing, not + an IronClaw client concern. +- Migrating the legacy per-scope flow away; it remains the user-invite model. From 2aad1dc28a7b523109d6c60e80d6763ae3aaeb66 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 09:21:13 -0700 Subject: [PATCH 02/46] =?UTF-8?q?docs:=20Slice=200=20plan=20=E2=80=94=20tr?= =?UTF-8?q?ace-commons-server=20per-user=20subject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD plan for the one server change the whole effort depends on: accept an optional opaque subject in the upload-claim request and derive a per-user, tenant-namespaced principal at device-key issuance. Submission attribution, login-link account resolution, and trace readback all become per-user automatically from the shared bearer principal; absent subject reproduces today's behavior. Targets trace-commons-server (contributor-account-slice1). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-25-trace-commons-slice0-server-subject.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-trace-commons-slice0-server-subject.md diff --git a/docs/superpowers/plans/2026-06-25-trace-commons-slice0-server-subject.md b/docs/superpowers/plans/2026-06-25-trace-commons-slice0-server-subject.md new file mode 100644 index 00000000000..7f8691ccf32 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-trace-commons-slice0-server-subject.md @@ -0,0 +1,346 @@ +# Trace Commons Slice 0 — Server per-user subject Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Target repo:** `trace-commons-server`. Working copy: +> `/Users/zakimanian/code/trace-commons-server/.worktrees/contributor-account-slice1-impl/` +> (branch `contributor-account-slice1`). This is a SERVER plan; the IronClaw +> client slices (1–4) are planned separately and depend on this one being merged +> or contract-frozen. + +**Goal:** Let one enrolled device key mint per-user contributor claims by +accepting an optional, opaque `subject` in the upload-claim request, so a single +IronClaw instance fans out to many per-user accounts within its own tenant. + +**Architecture:** The account endpoints (`/v1/account/*`) and the submission +endpoint (`/v1/traces`) both derive their principal from the bearer JWT's +`principal_ref` claim (`authenticate` → `TenantAuth.principal_ref`, set from +`issue_claim_for_authorized_actor`'s `principal_ref: actor.actor`). Therefore the +only required change is to make the device-key claim's `actor` per-user when a +`subject` is supplied. Submission attribution, login-link account resolution, and +trace-readback ownership all become per-user automatically — no separate +login-link or submission change. The change is additive: absent `subject` +reproduces today's behavior byte-for-byte. + +**Tech Stack:** Rust, axum, jsonwebtoken (EdDSA), tokio, serde. Spec: +`docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md` +(in the ironclaw repo). + +## Global Constraints + +- All file paths below are relative to the working copy + `/Users/zakimanian/code/trace-commons-server/.worktrees/contributor-account-slice1-impl/`. +- Zero clippy warnings: the repo gates with `RUSTFLAGS="-D warnings"`. +- Backward compatibility is mandatory: a request with no `subject` MUST produce + an identical claim (`sub`/`principal_ref` == raw `device_key_id`) to today. +- `subject` is already an opaque pseudonymous token minted by the client + (`tenant_sha256:…`-style); the server treats it as opaque and MUST NOT log it + raw beyond existing claim handling, and MUST NOT trust any client-supplied + principal prefix — the server derives the namespaced principal itself. +- Test command: `cargo test -p trace-commons-server --lib trace_upload_claim_issuer` + (compile gate: `RUSTFLAGS="-D warnings" cargo test -p trace-commons-server --no-run`). + +--- + +### Task 1: Accept and validate an optional `subject` in the claim request + +**Files:** +- Modify: `crates/trace-commons-server/src/trace_upload_claim_issuer.rs:536-552` (struct) +- Modify: `crates/trace-commons-server/src/trace_upload_claim_issuer.rs` (add `normalize_subject` helper near `principal_storage_ref` at ~2168) +- Test: `crates/trace-commons-server/src/trace_upload_claim_issuer.rs` (test module starts at line 2260) + +**Interfaces:** +- Produces: `TraceUploadClaimRequest.subject: Option` (`#[serde(default)]`). +- Produces: `fn normalize_subject(raw: &str) -> Result` — + trims, rejects empty / over-128-byte / non-`[A-Za-z0-9:_-]` values, returns the + normalized subject. Consumed by Task 2. + +- [ ] **Step 1: Write the failing test** + +Add to the test module (after line 2260): + +```rust +#[test] +fn normalize_subject_accepts_pseudonymous_token() { + let s = normalize_subject(" tenant_sha256:ab12CD_- ").expect("valid"); + assert_eq!(s, "tenant_sha256:ab12CD_-"); +} + +#[test] +fn normalize_subject_rejects_empty_and_oversized_and_bad_chars() { + assert!(normalize_subject(" ").is_err()); + assert!(normalize_subject(&"a".repeat(129)).is_err()); + assert!(normalize_subject("has space").is_err()); + assert!(normalize_subject("bad/slash").is_err()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p trace-commons-server --lib trace_upload_claim_issuer::tests::normalize_subject` +Expected: FAIL — `cannot find function normalize_subject`. + +- [ ] **Step 3: Add the `subject` field and the helper** + +Add `subject` to the struct (insert before `requested_at`): + +```rust + #[serde(default)] + allowed_uses: Vec, + #[serde(default)] + subject: Option, + requested_at: DateTime, +} +``` + +Add the helper next to `principal_storage_ref` (~line 2168): + +```rust +/// Maximum accepted byte length for a client-supplied subject. +const MAX_SUBJECT_LEN: usize = 128; + +/// Validate and normalize an opaque per-user subject. The subject is a +/// pseudonymous token minted by the client; we only enforce a conservative +/// shape so it is safe to embed in a derived principal string. We never trust a +/// client-supplied principal prefix — the namespaced principal is built in +/// `issue_claim_for_device_key`. +fn normalize_subject(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.len() > MAX_SUBJECT_LEN { + return Err(IssuerError::bad_request("invalid subject")); + } + if !trimmed + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'_' | b'-')) + { + return Err(IssuerError::bad_request("invalid subject")); + } + Ok(trimmed.to_string()) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p trace-commons-server --lib trace_upload_claim_issuer::tests::normalize_subject` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trace-commons-server/src/trace_upload_claim_issuer.rs +git commit -m "feat(claim): accept optional opaque subject in upload-claim request" +``` + +--- + +### Task 2: Derive a per-user principal in device-key claim issuance + +**Files:** +- Modify: `crates/trace-commons-server/src/trace_upload_claim_issuer.rs:1405-1443` + (`issue_claim_for_device_key`) +- Test: `crates/trace-commons-server/src/trace_upload_claim_issuer.rs` (test module) + +**Interfaces:** +- Consumes: `TraceUploadClaimRequest.subject` + `normalize_subject` (Task 1). +- Produces: when `subject` is present, the issued claim's `sub` and + `principal_ref` equal `instance:{tenant_id}:{device_key_id}:user:{subject}`; + when absent, they equal the raw `device_key_id` (unchanged). The + tenant-access-grant principal (`grant_principal_ref`) stays device-scoped + (`principal_sha256:` of `device:{tenant}:{device_key_id}`) in both cases — + grants are governed at the device level. + +- [ ] **Step 1: Write the failing tests** + +Add to the test module. (Reuse the existing device-key claim test helpers — find +the existing device-key issue test, e.g. search the module for +`issue_claim_for_device_key` / a `device_claim_request(...)` helper, and mirror +its setup. The two assertions that matter:) + +```rust +#[tokio::test] +async fn device_claim_without_subject_uses_raw_device_key_id() { + // ARRANGE: mirror the existing device-key happy-path test setup, with a + // request whose `subject` is None. + let (status, body) = post_device_claim(/* existing helpers */).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let claims = decode_issued_claims(&body); + assert_eq!(claims["principal_ref"], DEVICE_KEY_ID); // unchanged behavior + assert_eq!(claims["sub"], DEVICE_KEY_ID); +} + +#[tokio::test] +async fn device_claim_with_subject_yields_distinct_per_user_principal() { + let (s1, b1) = post_device_claim_with_subject("user-alice-hash").await; + let (s2, b2) = post_device_claim_with_subject("user-bob-hash").await; + assert_eq!(s1, StatusCode::OK); + assert_eq!(s2, StatusCode::OK); + let p1 = decode_issued_claims(&b1)["principal_ref"].as_str().unwrap().to_string(); + let p2 = decode_issued_claims(&b2)["principal_ref"].as_str().unwrap().to_string(); + assert_eq!( + p1, + format!("instance:{TENANT_ID}:{DEVICE_KEY_ID}:user:user-alice-hash") + ); + assert_ne!(p1, p2, "distinct subjects must yield distinct principals"); +} +``` + +> Note for the implementer: the existing module already has device-key issue +> tests with signing helpers (`verify_device_claim_signature`, a test device +> keypair, and a `post_claim`/`post_device_claim` style helper). Use those exact +> helpers and constants rather than inventing new ones; `decode_issued_claims` is +> the same decode block used in +> `eddsa_only_issue_success_returns_bounded_upload_claim` (lines 2475-2512) — +> extract it into a small local helper if it isn't one already. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p trace-commons-server --lib trace_upload_claim_issuer::tests::device_claim` +Expected: FAIL — `device_claim_with_subject_*` fails because `principal_ref` +still equals the raw device key id. + +- [ ] **Step 3: Thread the subject into the derived actor** + +In `issue_claim_for_device_key`, replace the `actor` / `grant_principal_ref` +block (currently lines ~1437-1438): + +```rust + let actor = auth.device_key_id; + let grant_principal_ref = principal_storage_ref(&format!("device:{tenant_id}:{actor}")); +``` + +with: + +```rust + let device_key_id = auth.device_key_id; + // Grants are governed at the device level regardless of per-user subject. + let grant_principal_ref = + principal_storage_ref(&format!("device:{tenant_id}:{device_key_id}")); + // When the instance asserts a per-user subject, the issued principal is + // namespaced under the device so subjects cannot collide across + // instances/tenants and the blast radius stays inside this tenant. Absent a + // subject, behavior is unchanged (principal == raw device_key_id). + let actor = match request.subject.as_deref() { + Some(raw) => { + let subject = normalize_subject(raw)?; + format!("instance:{tenant_id}:{device_key_id}:user:{subject}") + } + None => device_key_id, + }; +``` + +(The `AuthorizedUploadClaimActor { actor, ... }` construction below is unchanged; +it already sets `sub`/`principal_ref` from `actor`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p trace-commons-server --lib trace_upload_claim_issuer::tests::device_claim` +Expected: PASS (both new tests + the existing device-key tests). + +- [ ] **Step 5: Full compile + lint gate** + +Run: `RUSTFLAGS="-D warnings" cargo test -p trace-commons-server --no-run` +Expected: builds with zero warnings. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trace-commons-server/src/trace_upload_claim_issuer.rs +git commit -m "feat(claim): derive per-user principal from optional subject for device keys" +``` + +--- + +### Task 3: Verify per-user claim flows through account resolution (integration) + +**Files:** +- Test: add an integration test alongside the existing account/login-link tests + (find them via `grep -rn "mint_login_link\|create_or_reuse_account" crates/trace-commons-server` — + likely in the bin's test module or a `tests/` integration file; co-locate with + the existing login-link test setup so the DB/test harness is reused). + +**Interfaces:** +- Consumes: a per-user upload claim from Task 2. +- Produces: proof that a per-user bearer (1) authenticates on + `/v1/account/login-links`, (2) resolves a per-user account, and (3) two + distinct subjects under one device key resolve to two distinct accounts, while + a no-subject bearer resolves to the device-level account (today's behavior). + +> If the account endpoints require a different audience/role than the upload +> claim issues (`aud = trace-commons-upload`, `role = contributor`), this test +> will surface it. If they reject the contributor claim, STOP and escalate: the +> design assumed the same bearer works on `/v1/account/*`; the fix is a scoped +> follow-up (mint an account-audience claim or widen the account endpoints' +> accepted audience), not a silent workaround. + +- [ ] **Step 1: Write the failing test** + +Mirror the existing login-link test setup (real or testcontainer Postgres per the +repo's account-test harness), then: + +```rust +#[tokio::test] +async fn per_user_subjects_resolve_to_distinct_accounts_under_one_device_key() { + // ARRANGE: one enrolled device key in tenant T (reuse the existing + // account-test harness that seeds a device key + tenant). + // Mint two per-user claims via the issuer (subjects "alice", "bob") and one + // no-subject claim, then call POST /v1/account/login-links with each bearer. + let alice_account = mint_login_link_account_for_subject(Some("alice")).await; + let bob_account = mint_login_link_account_for_subject(Some("bob")).await; + let device_account = mint_login_link_account_for_subject(None).await; + + assert_ne!(alice_account, bob_account, "distinct subjects → distinct accounts"); + assert_ne!(alice_account, device_account); + // Idempotent reuse: same subject → same account. + assert_eq!(alice_account, mint_login_link_account_for_subject(Some("alice")).await); +} +``` + +- [ ] **Step 2: Run test to verify it fails (or surfaces the audience gap)** + +Run: `cargo test -p trace-commons-server per_user_subjects_resolve_to_distinct_accounts` +Expected: FAIL initially because the helper `mint_login_link_account_for_subject` +does not exist yet; once written, it either PASSES (design confirmed) or surfaces +the audience/role gap noted above. + +- [ ] **Step 3: Implement the test helper only (no production change expected)** + +Implement `mint_login_link_account_for_subject(subject: Option<&str>) -> Uuid`: +mint a device claim with the given subject via the issuer test helper, POST it to +the login-link handler, then read back the `account_id` from the +`MintLoginLinkResponse` (`account_id: String` → parse `Uuid`). No production code +change is expected — Tasks 1–2 already carry the subject through the principal. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p trace-commons-server per_user_subjects_resolve_to_distinct_accounts` +Expected: PASS. If it fails on audience/role, escalate per the note above before +making any change. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trace-commons-server +git commit -m "test(account): per-user subjects under one device key resolve to distinct accounts" +``` + +--- + +## Self-Review + +- **Spec coverage:** Spec §"Server changes" item 1 (optional `subject` field) → + Task 1. Item 2 (per-user principal in device-key issuance) → Task 2. Item 3 + (login-link account resolution accepts the per-user principal) → Task 3 + *verifies* this is automatic via the shared bearer principal, rather than a + separate code change, because `authenticate_ctx` derives `principal_ref` from + the same JWT. Backward-compat requirement → Task 2 Step 1 first assertion + + Task 3 no-subject branch. Subject-spoofing containment → Task 2 namespaced + derivation (server builds the principal; client prefix never trusted). +- **Placeholder scan:** The only deferred specifics are test-helper names that + must match the repo's existing device-key/account test harness; each such step + names the exact existing symbol to mirror (`eddsa_only_issue_success_…` decode + block, `mint_login_link_handler`, `MintLoginLinkResponse`) rather than leaving + it open. No "TODO/handle edge cases" steps. +- **Type consistency:** `subject: Option` (Task 1) is read as + `request.subject.as_deref()` (Task 2); `normalize_subject(&str) -> Result` + used consistently; `MintLoginLinkResponse.account_id: String` parsed to `Uuid` + in Task 3 matches the struct at server line 11837-11840. From 30fde025dab27d1b4a0d9b856a4469a0cf9b3575 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 09:38:28 -0700 Subject: [PATCH 03/46] docs: IronClaw plans for Trace Commons slices 1-4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1: trace-credential resolver (personal-invite wins, instance fallback with per-user subject) + admin-gated instance enrollment. Slice 2: per-user subject plumbing through upload-claim request + submission. Slice 3: trace_commons.account_login_link first-party capability (profiles). Slice 4: per-user submitted-trace inspection across reborn_traces → product_workflow facade → webui_v2 handler → frontend. Each plan is bite-sized TDD against verbatim-extracted current code. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ons-slice1-resolver-instance-enrollment.md | 403 +++++++++++++++++ ...5-trace-commons-slice2-subject-plumbing.md | 362 ++++++++++++++++ ...ce-commons-slice3-login-link-capability.md | 401 +++++++++++++++++ ...5-trace-commons-slice4-trace-inspection.md | 406 ++++++++++++++++++ 4 files changed, 1572 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-trace-commons-slice1-resolver-instance-enrollment.md create mode 100644 docs/superpowers/plans/2026-06-25-trace-commons-slice2-subject-plumbing.md create mode 100644 docs/superpowers/plans/2026-06-25-trace-commons-slice3-login-link-capability.md create mode 100644 docs/superpowers/plans/2026-06-25-trace-commons-slice4-trace-inspection.md 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 new file mode 100644 index 00000000000..07839054bdf --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-trace-commons-slice1-resolver-instance-enrollment.md @@ -0,0 +1,403 @@ +# Trace Commons Slice 1 — Credential resolver + instance enrollment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Target repo:** `ironclaw` (`/Users/zakimanian/code/ironclaw`). Independent of +> Slice 0 (it adds a resolver + admin-gated instance enrollment that writes an +> instance-level policy; the per-user `subject` it produces is *consumed* by +> Slice 2). Safe to build and merge before Slice 0 lands. + +**Goal:** Add a trace-credential resolver that picks a user's own (personal-invite) +enrollment when present, else falls back to an admin-provisioned instance +enrollment with a per-user pseudonymous subject — and add the admin-gated +instance-enrollment entry point. + +**Architecture:** Trace state today is keyed per-scope under +`trace_contribution_dir_for_scope(Some(scope))/policy.json`. We add an +*instance-level* policy (scope `None` → base dir `policy.json`) written only by an +admin path, and a resolver that returns `{ scope_for_state, subject }` so all +downstream callers consult one function instead of reading a per-scope policy +directly. Personal-invite enrollment wins; instance enrollment is the fallback +and carries a per-user subject = `local_pseudonymous_contributor_id("{tenant}:{user}")`. + +**Tech Stack:** Rust, tokio, serde, anyhow. Spec: +`docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md`. + +## Global Constraints + +- No `.unwrap()`/`.expect()` in production code (tests are fine). +- Zero clippy warnings: `cargo clippy --all --benches --tests --examples --all-features`. +- Map errors with context per CLAUDE.md. +- Prompt/large strings are not involved here. +- Unit tests: `cargo test -p ironclaw_reborn_traces`. Crate-level admin tests: + `cargo test` (and `--features integration` where DB is needed). +- "Test through the caller" (CLAUDE.md): the resolver gates a network side + effect, so add a test that drives the *caller* (the first-party dispatch path + in Slice 3/Task 4 here covers the admin enrollment caller), not only the + resolver helper. + +--- + +### Task 1: `TraceCredentialResolution` type + resolver + +**Files:** +- Modify: `crates/ironclaw_reborn_traces/src/contribution.rs` (add resolver near + `read_trace_policy_for_scope` at line 4058; reuse `trace_scope_key` line 4001, + `local_pseudonymous_contributor_id` line 4013) +- Test: same file, test module. + +**Interfaces:** +- Produces: + ```rust + pub struct TraceCredentialResolution { + /// The scope string whose local state (policy, device key, credits) to use. + pub state_scope: String, + /// Per-user subject to send in upload-claim / login-link requests. + /// `None` for the personal-invite model (device key already 1:1 with user). + pub subject: Option, + /// The resolved enrollment policy. + pub policy: StandingTraceContributionPolicy, + } + + pub fn resolve_trace_credentials( + tenant_id: &str, + user_id: &str, + ) -> anyhow::Result>; + ``` + Returns `None` when neither a personal nor an instance enrollment is enabled. +- Consumes: `read_trace_policy_for_scope`, `trace_scope_key`, + `local_pseudonymous_contributor_id` (all existing). + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn resolver_prefers_personal_invite_enrollment_with_no_subject() { + let dir = tempfile::tempdir().unwrap(); + crate::test_support::with_base_dir(dir.path(), || { + let scope = trace_scope_key("tenant-a", "alice"); + let mut personal = StandingTraceContributionPolicy::default(); + personal.enabled = true; + write_trace_policy_for_scope(Some(scope.as_str()), &personal).unwrap(); + + let r = resolve_trace_credentials("tenant-a", "alice").unwrap().unwrap(); + assert_eq!(r.state_scope, scope); + assert_eq!(r.subject, None, "personal invite carries no subject"); + assert!(r.policy.enabled); + }); +} + +#[test] +fn resolver_falls_back_to_instance_enrollment_with_per_user_subject() { + let dir = tempfile::tempdir().unwrap(); + crate::test_support::with_base_dir(dir.path(), || { + // No personal policy; only the instance-level (scope None) policy. + let mut instance = StandingTraceContributionPolicy::default(); + instance.enabled = true; + write_trace_policy_for_scope(None, &instance).unwrap(); + + let r = resolve_trace_credentials("tenant-a", "alice").unwrap().unwrap(); + let expected_scope = trace_scope_key("tenant-a", "alice"); + assert_eq!(r.subject, Some(local_pseudonymous_contributor_id(&expected_scope))); + assert!(r.policy.enabled); + }); +} + +#[test] +fn resolver_returns_none_when_unenrolled() { + let dir = tempfile::tempdir().unwrap(); + crate::test_support::with_base_dir(dir.path(), || { + assert!(resolve_trace_credentials("tenant-a", "alice").unwrap().is_none()); + }); +} +``` + +> If `crate::test_support::with_base_dir` does not exist, the tests must instead +> set the base dir the way the existing onboarding/contribution tests do — check +> how `trace_contribution_dir_for_scope` resolves its base +> (`ironclaw_common::paths::ironclaw_base_dir()`) and mirror the existing +> base-dir override used by tests in this crate (search the test module for how +> other tests isolate the base dir; the e2e test uses `setup_base_dir()`). +> Replace the wrapper with that mechanism if needed — do NOT invent a new global. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p ironclaw_reborn_traces resolver_` +Expected: FAIL — `resolve_trace_credentials` not found. + +- [ ] **Step 3: Implement the resolver** + +```rust +/// Resolved Trace Commons credentials for a (tenant, user): which local-state +/// scope to use and the per-user subject (if any) to send to the server. +#[derive(Debug, Clone, PartialEq)] +pub struct TraceCredentialResolution { + pub state_scope: String, + pub subject: Option, + pub policy: StandingTraceContributionPolicy, +} + +/// 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 enabled. +pub fn resolve_trace_credentials( + tenant_id: &str, + user_id: &str, +) -> anyhow::Result> { + let scope = trace_scope_key(tenant_id, user_id); + + let personal = read_trace_policy_for_scope(Some(scope.as_str()))?; + if personal.enabled { + return Ok(Some(TraceCredentialResolution { + state_scope: scope, + subject: None, + policy: personal, + })); + } + + let instance = read_trace_policy_for_scope(None)?; + if instance.enabled { + return Ok(Some(TraceCredentialResolution { + subject: Some(local_pseudonymous_contributor_id(&scope)), + state_scope: scope, + policy: instance, + })); + } + + Ok(None) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p ironclaw_reborn_traces resolver_` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/contribution.rs +git commit -m "feat(traces): trace-credential resolver (personal invite wins, instance fallback w/ subject)" +``` + +--- + +### Task 2: Instance-enrollment write path (scope = None) + +**Files:** +- Modify: `crates/ironclaw_reborn_traces/src/onboarding/mod.rs` (add a thin + instance-enrollment entry that targets the base dir) +- Test: `crates/ironclaw_reborn_traces/src/onboarding/tests.rs` + +**Interfaces:** +- Consumes: `onboard_at_dir_with_sink` (existing, line 207), + `trace_contribution_dir_for_scope(None)` (existing, line 3991 in contribution.rs). +- Produces: + ```rust + pub async fn onboard_instance_with_sink( + invite_url: &str, + consents: OnboardConsents, + sink: &dyn OnboardingHttpSink, + ) -> Result; + ``` + Writes the enrollment policy to the **instance-level** location + (`trace_contribution_dir_for_scope(None)/policy.json`), making it the resolver's + fallback (Task 1). + +- [ ] **Step 1: Write the failing test** + +```rust +#[tokio::test] +async fn instance_onboard_writes_instance_level_policy() { + let base = tempfile::tempdir().unwrap(); + // Point the crate base dir at `base` the same way other tests in this file + // isolate it (mirror `successful_onboard_writes_policy_and_promotes_key`'s + // dir handling, but for scope None → base/trace_contributions/policy.json). + 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#INVTEST01", mock.addr.port()); + + let outcome = onboard_instance_with_sink( + &invite_url, + OnboardConsents::default(), + &DefaultOnboardingHttpSink, + ) + .await + .expect("instance onboard succeeds"); + + assert_eq!(outcome.tenant_id, "tenant-a"); + // Instance policy is at the base (scope None), not under users//. + let policy = read_trace_policy_for_scope(None).unwrap(); + assert!(policy.enabled); + assert_eq!(policy.device_key_id.as_deref(), Some(outcome.device_key_id.as_str())); +} +``` + +> Use the same base-dir isolation the existing onboarding tests use. The assert +> that matters is "policy lands at scope `None`", which is what makes the +> resolver's instance fallback fire. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p ironclaw_reborn_traces instance_onboard_writes_instance_level_policy` +Expected: FAIL — `onboard_instance_with_sink` not found. + +- [ ] **Step 3: Implement the instance-enrollment entry** + +In `onboarding/mod.rs`, next to `onboard` (line 195): + +```rust +/// Instance-wide enrollment: identical to [`onboard`] but writes the resulting +/// `StandingTraceContributionPolicy` to the instance-level location +/// (`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. +pub async fn onboard_instance_with_sink( + invite_url: &str, + consents: OnboardConsents, + sink: &dyn OnboardingHttpSink, +) -> Result { + let dir = trace_contribution_dir_for_scope(None); + onboard_at_dir_with_sink(&dir, invite_url, consents, sink).await +} +``` + +Ensure `trace_contribution_dir_for_scope` is imported (it already is for the +per-scope `onboard`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p ironclaw_reborn_traces instance_onboard_writes_instance_level_policy` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/onboarding/mod.rs crates/ironclaw_reborn_traces/src/onboarding/tests.rs +git commit -m "feat(traces): instance-level enrollment write path (scope None)" +``` + +--- + +### Task 3: Admin-gated instance enrollment on `AdminScope` + +**Files:** +- Modify: `src/tenant.rs:933-978` (add a method to `AdminScope`) +- Test: `src/tenant.rs` test module (mirror + `test_admin_scope_new_returns_some_for_admin` at line 1195) + +**Interfaces:** +- Consumes: `ironclaw_reborn_traces::onboarding::onboard_instance_with_sink` + (Task 2) and the host egress onboarding sink. Because `AdminScope` has no + egress handle, the method takes the sink as a parameter so the host wires it. +- Produces: + ```rust + impl AdminScope { + pub async fn enroll_instance_trace_commons( + &self, + invite_url: &str, + consents: ironclaw_reborn_traces::onboarding::OnboardConsents, + sink: &dyn ironclaw_reborn_traces::onboarding::OnboardingHttpSink, + ) -> Result; + } + ``` + The mere existence of `&self: AdminScope` is the gate — it is unconstructable + for non-admins (`AdminScope::new` returns `None`). + +- [ ] **Step 1: Write the failing test** + +```rust +#[tokio::test] +async fn admin_scope_exposes_instance_trace_enrollment() { + // Compile-level gate test: a Regular identity cannot even obtain AdminScope, + // so the method is unreachable for them. Confirm the method exists on the + // admin-constructed scope (we don't perform real network here). + let scope = AdminScope::new(admin_identity(), test_db().await) + .expect("admin constructs scope"); + // The method is async + network-bound; assert it is addressable by taking a + // function pointer (no call). This documents the gate without a live POST. + let _f = AdminScope::enroll_instance_trace_commons; + let _ = scope; // scope is the gate; non-admins never reach this method. +} +``` + +> The real network behavior is covered by Task 2's integration test and the +> Slice 3 dispatch caller test. This test asserts the gate placement (method +> lives on `AdminScope`, which non-admins cannot construct). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test admin_scope_exposes_instance_trace_enrollment` +Expected: FAIL — method `enroll_instance_trace_commons` not found. + +- [ ] **Step 3: Implement the method** + +Add inside `impl AdminScope` (after `deactivate_user`, line 977): + +```rust + // === Trace Commons instance enrollment === + + /// Enroll this IronClaw instance with Trace Commons using an operator invite + /// link. Writes the instance-level enrollment policy that non-personally- + /// enrolled users inherit via `resolve_trace_credentials`. Admin-only by + /// construction: `AdminScope` is unconstructable for non-admin identities. + /// The `sink` is supplied by the host so the POST routes through the + /// deployment's network-egress policy. + pub async fn enroll_instance_trace_commons( + &self, + invite_url: &str, + consents: ironclaw_reborn_traces::onboarding::OnboardConsents, + sink: &dyn ironclaw_reborn_traces::onboarding::OnboardingHttpSink, + ) -> Result< + ironclaw_reborn_traces::onboarding::OnboardOutcome, + ironclaw_reborn_traces::onboarding::OnboardError, + > { + ironclaw_reborn_traces::onboarding::onboard_instance_with_sink( + invite_url, consents, sink, + ) + .await + } +``` + +- [ ] **Step 4: Run test to verify it passes + clippy** + +Run: `cargo test admin_scope_exposes_instance_trace_enrollment` +Then: `cargo clippy --all --tests` +Expected: PASS; zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add src/tenant.rs +git commit -m "feat(admin): AdminScope::enroll_instance_trace_commons (admin-gated instance enrollment)" +``` + +--- + +## Self-Review + +- **Spec coverage:** Spec §"Two coexisting models, one resolver" + precedence → + Task 1 (`resolve_trace_credentials`, personal wins, instance fallback w/ + subject, `None` when unenrolled). §"IronClaw changes" item 2 (instance + enrollment, additive, instance-level policy) → Task 2 (`onboard_instance_with_sink` + → scope `None`) + Task 3 (admin gate). Per-scope flow untouched: `onboard` + (line 195) is not modified. +- **Placeholder scan:** The only deferred specifics are the crate's base-dir test + isolation mechanism (named: mirror `successful_onboard_writes_policy_and_promotes_key` + / `setup_base_dir`), not open TODOs. No "add validation"/"handle edge cases" + steps. +- **Type consistency:** `TraceCredentialResolution { state_scope, subject, policy }` + defined Task 1, not referenced by name in later tasks (Slice 2 consumes it). + `onboard_instance_with_sink(invite_url, consents, sink)` signature identical in + Task 2 (def) and Task 3 (call). `OnboardConsents`/`OnboardOutcome`/`OnboardError` + paths match the extracted onboarding module. +- **Note for Slice 2/3/4:** downstream callers must switch from + `read_trace_policy_for_scope(Some(scope))` to `resolve_trace_credentials(tenant, + user)` and pass `resolution.subject` into claim/login-link requests, and use + `resolution.state_scope` for local-state reads. diff --git a/docs/superpowers/plans/2026-06-25-trace-commons-slice2-subject-plumbing.md b/docs/superpowers/plans/2026-06-25-trace-commons-slice2-subject-plumbing.md new file mode 100644 index 00000000000..8d39060c27c --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-trace-commons-slice2-subject-plumbing.md @@ -0,0 +1,362 @@ +# Trace Commons Slice 2 — Per-user subject plumbing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Target repo:** `ironclaw`. **Depends on Slice 0** (server accepting the +> `subject` field) for end-to-end effect, and on **Slice 1** (`TraceCredentialResolution.subject`). +> The client change is safe to merge before Slice 0: a server that ignores the +> extra field still works; the subject simply has no effect until Slice 0 lands. + +**Goal:** Carry the resolver's per-user `subject` into the trace upload-claim +request so submissions under the shared instance device key are attributed +per-user. + +**Architecture:** `TraceUploadClaimContext` gains an optional `subject`; the +request builder serializes it (only in `DeviceKey` auth mode, mirroring the +`invite_code` precedent); the submission path obtains the subject from +`resolve_trace_credentials` (Slice 1) and threads it into the context. + +**Tech Stack:** Rust, serde, reqwest, tokio. Spec: +`docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md`. + +## Global Constraints + +- No `.unwrap()`/`.expect()` in production code. +- Zero clippy warnings. +- Backward compatible: when `subject` is `None`, the serialized request body is + byte-identical to today (field omitted via `skip_serializing_if`). +- Wire field name MUST be `subject` to match the Slice 0 server struct field. +- Tests: `cargo test -p ironclaw_reborn_traces`. + +--- + +### Task 1: Add `subject` to the upload-claim request body + +**Files:** +- Modify: `crates/ironclaw_reborn_traces/src/contribution.rs:4546-4568` + (`TraceUploadClaimIssuerRequest`) and `:5076-5102` + (`build_trace_upload_claim_issuer_request`) and `:4467-4478` + (`TraceUploadClaimContext`) +- Test: same file, test module (mirror + `fetch_trace_upload_claim_from_issuer_accepts_loopback_dev_issuer` at 12558). + +**Interfaces:** +- Produces: `TraceUploadClaimContext.subject: Option` and + `TraceUploadClaimIssuerRequest.subject: Option` (`skip_serializing_if = "Option::is_none"`). + The builder copies context→request only in `DeviceKey` auth mode. + +- [ ] **Step 1: Write the failing test** + +Add a test that asserts the serialized request includes `subject` when set in +`DeviceKey` mode and omits it when `None`. The cleanest assertion is on the +builder output (pure function, no network): + +```rust +#[test] +fn upload_claim_request_includes_subject_in_device_key_mode() { + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_tenant_id: Some("tenant-a".to_string()), + ..Default::default() + }; + let ctx = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: vec![ConsentScope::DebuggingEvaluation], + allowed_uses: Vec::new(), + scope_dir: None, + subject: Some("sha256:deadbeef".to_string()), + }; + let req = build_trace_upload_claim_issuer_request(&policy, &ctx); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["subject"], "sha256:deadbeef"); +} + +#[test] +fn upload_claim_request_omits_subject_when_none() { + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + ..Default::default() + }; + let ctx = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: Vec::new(), + allowed_uses: Vec::new(), + scope_dir: None, + subject: None, + }; + let req = build_trace_upload_claim_issuer_request(&policy, &ctx); + let json = serde_json::to_value(&req).unwrap(); + assert!(json.get("subject").is_none(), "subject omitted when None"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p ironclaw_reborn_traces upload_claim_request_` +Expected: FAIL — `TraceUploadClaimContext` has no field `subject` (compile error). + +- [ ] **Step 3: Add the fields and builder copy** + +Add to `TraceUploadClaimContext` (after `scope_dir`, line ~4477): + +```rust + /// Per-user pseudonymous subject (from `resolve_trace_credentials`). When + /// set and auth_mode is DeviceKey, it is sent to the issuer so the minted + /// claim's principal is per-user under the shared instance device key. + /// `None` for the personal-invite model (device key already 1:1 with user). + subject: Option, +``` + +Add to `TraceUploadClaimIssuerRequest` (after `invite_code`, line ~4567): + +```rust + /// Per-user subject; only sent in DeviceKey mode. The server (Slice 0) + /// derives a per-user principal from it. Omitted when absent. + #[serde(skip_serializing_if = "Option::is_none")] + subject: Option, +``` + +In `build_trace_upload_claim_issuer_request` (line 5076), compute and set it: + +```rust + // Per-user subject only applies to the device-key (instance) path; in + // WorkloadTokenEnv mode the workload token already identifies the principal. + let subject = match policy.auth_mode { + TraceUploadAuthMode::DeviceKey => context.subject.clone(), + TraceUploadAuthMode::WorkloadTokenEnv => None, + }; + TraceUploadClaimIssuerRequest { + schema_version: "ironclaw.trace_upload_claim_request.v1", + tenant_id: policy.upload_token_tenant_id.clone(), + audience: policy.upload_token_audience.clone(), + trace_id: context.trace_id, + submission_id: context.submission_id, + consent_scopes: context.consent_scopes.clone(), + allowed_uses: context.allowed_uses.clone(), + requested_at: Utc::now(), + invite_code, + subject, + } +``` + +Then fix every other construction site of `TraceUploadClaimContext` to set +`subject: None` (search the crate for `TraceUploadClaimContext {` and +`TraceUploadClaimContext::for_envelope`). For `for_envelope` (line ~4481), add +`subject: None` to its struct literal — Task 2 adds the setter. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p ironclaw_reborn_traces upload_claim_request_` +Expected: PASS (2 tests). Also run `cargo test -p ironclaw_reborn_traces` to +confirm no other `TraceUploadClaimContext` construction site is left unfixed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/contribution.rs +git commit -m "feat(traces): carry optional per-user subject in upload-claim request" +``` + +--- + +### Task 2: Thread subject from the resolver into submission + +**Files:** +- Modify: `crates/ironclaw_reborn_traces/src/contribution.rs` — + `TraceUploadClaimContext::for_envelope` (line ~4481) gains a `with_subject` + builder; the submission entry that builds the context + (`submit_trace_envelope_to_endpoint_with_credential_provider`, line 5978, and + its callers) sets the subject from `resolve_trace_credentials`. +- Test: same file, test module. + +**Interfaces:** +- Consumes: `resolve_trace_credentials` (Slice 1, Task 1). +- Produces: `TraceUploadClaimContext::with_subject(self, Option) -> Self` + and a submission path that, given `(tenant_id, user_id)`, attaches + `resolution.subject` to the claim context. + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +fn context_with_subject_sets_field() { + let ctx = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: Vec::new(), + allowed_uses: Vec::new(), + scope_dir: None, + subject: None, + } + .with_subject(Some("sha256:abc".to_string())); + assert_eq!(ctx.subject.as_deref(), Some("sha256:abc")); +} +``` + +> The end-to-end "submission carries subject" behavior is verified against a +> mock issuer in Task 3; this unit test locks the builder. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p ironclaw_reborn_traces context_with_subject_sets_field` +Expected: FAIL — no method `with_subject`. + +- [ ] **Step 3: Add the builder and wire the submission path** + +Add near `with_scope_dir` (the existing builder used at line ~5985): + +```rust + fn with_subject(mut self, subject: Option) -> Self { + self.subject = subject; + self + } +``` + +In the submission path that has `(tenant_id, user_id)` in scope (the scope-aware +submit caller — search for the function that calls +`submit_trace_envelope_to_endpoint_with_credential_provider` with a scope/user), +resolve and attach the subject. Where the context is built with `scope_dir`, +extend it: + +```rust + // Attach the per-user subject so instance-enrolled users are attributed + // individually. Personal-invite enrollments resolve to `subject: None`. + let subject = crate::contribution::resolve_trace_credentials(tenant_id, user_id) + .ok() + .flatten() + .and_then(|r| r.subject); + let context = TraceUploadClaimContext::for_envelope(envelope) + .with_scope_dir(dir.to_path_buf()) + .with_subject(subject); +``` + +> If the submission entry point does not currently receive `(tenant_id, user_id)` +> (only a `scope` string or `scope_dir`), thread them through from the nearest +> caller that has them (the host dispatch / web handler passes user id). Prefer +> adding parameters over re-deriving identity. Keep `subject: None` for any CLI/ +> worker path that has no user context — that preserves today's behavior. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p ironclaw_reborn_traces context_with_subject_sets_field` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/contribution.rs +git commit -m "feat(traces): thread resolver subject into submission claim context" +``` + +--- + +### Task 3: End-to-end — submission sends subject to the issuer + +**Files:** +- Test: `crates/ironclaw_reborn_traces/src/contribution.rs` test module (mirror + the loopback mock-issuer test at 12558, capturing the request body). + +**Interfaces:** +- Consumes: Tasks 1–2. +- Produces: a regression test that the minted-claim request body contains the + expected `subject` for an instance-enrolled user, and omits it for a + personal-invite user. + +- [ ] **Step 1: Write the failing test** + +```rust +#[tokio::test] +async fn fetch_claim_sends_subject_when_present() { + use std::sync::{Arc, Mutex}; + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + let token = test_jwt_with_header(serde_json::json!({"alg":"EdDSA","kid":"dev-key-1"})); + let claim_token = token.clone(); + let app = axum::Router::new().route( + "/v1/trace-upload-claim", + axum::routing::post(move |axum::Json(body): axum::Json| { + let cap = cap.clone(); + let token = claim_token.clone(); + async move { + cap.lock().unwrap().push(body); + axum::Json(serde_json::json!({ + "access_token": token, "token_type": "Bearer", "expires_in": 300 + })) + } + }), + ); + 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 scope_dir = tempfile::tempdir().unwrap(); + crate::onboarding::DeviceKeypair::load_or_generate_pending(scope_dir.path(), "h") + .unwrap() + .promote(scope_dir.path(), "tenant-dev") + .unwrap(); + + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), + upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from(["127.0.0.1".to_string()]), + upload_token_tenant_id: Some("tenant-dev".to_string()), + upload_token_audience: Some("trace-commons".to_string()), + ..Default::default() + }; + let context = TraceUploadClaimContext { + trace_id: None, submission_id: None, + consent_scopes: vec![ConsentScope::DebuggingEvaluation], + allowed_uses: Vec::new(), + scope_dir: Some(scope_dir.path().to_path_buf()), + subject: Some("sha256:alice".to_string()), + }; + let _ = fetch_trace_upload_claim_from_issuer(&policy, &context, None).await.unwrap(); + + let bodies = captured.lock().unwrap(); + assert_eq!(bodies.len(), 1); + assert_eq!(bodies[0]["subject"], "sha256:alice"); +} +``` + +- [ ] **Step 2: Run test to verify it fails (then passes after Tasks 1–2)** + +Run: `cargo test -p ironclaw_reborn_traces fetch_claim_sends_subject_when_present` +Expected: with Tasks 1–2 implemented, PASS. (If run before them, it fails to +compile / the body lacks `subject`.) + +- [ ] **Step 3: (no new production code)** — this task is the regression lock. + +- [ ] **Step 4: Full crate test + clippy** + +Run: `cargo test -p ironclaw_reborn_traces` then +`cargo clippy --all --tests` +Expected: PASS; zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/contribution.rs +git commit -m "test(traces): claim request carries per-user subject end-to-end" +``` + +--- + +## Self-Review + +- **Spec coverage:** Spec §"IronClaw changes" item 3 (per-user subject plumbing + through claim + submission) → Task 1 (request/context field + builder), Task 2 + (resolver→context wiring), Task 3 (e2e regression). Backward-compat → Task 1 + `skip_serializing_if` + `upload_claim_request_omits_subject_when_none`. +- **Placeholder scan:** Task 2 Step 3 flags a threading decision (pass + `tenant_id`/`user_id` from the nearest caller) with an explicit rule + (add params, don't re-derive; `None` for CLI/worker) — not an open TODO. +- **Type consistency:** field name `subject` consistent across + `TraceUploadClaimContext`, `TraceUploadClaimIssuerRequest`, the wire JSON, and + the Slice 0 server struct. `with_subject(Option) -> Self` matches its + call in Task 2. `resolve_trace_credentials(tenant, user) -> Option` + matches Slice 1 Task 1. diff --git a/docs/superpowers/plans/2026-06-25-trace-commons-slice3-login-link-capability.md b/docs/superpowers/plans/2026-06-25-trace-commons-slice3-login-link-capability.md new file mode 100644 index 00000000000..7ded36087be --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-trace-commons-slice3-login-link-capability.md @@ -0,0 +1,401 @@ +# Trace Commons Slice 3 — Account login-link capability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Target repo:** `ironclaw`. **Depends on Slice 0** (server `/v1/account/login-links` +> + per-user subject) and **Slices 1–2** (resolver + subject). Until Slice 0 is +> live, the capability returns the server's error verbatim. + +**Goal:** Add a consent-gated, model-visible first-party capability +`trace_commons.account_login_link` that mints a one-time Trace Commons browser +login URL for the authenticated user (so they can manage their contributor +profile / account in the web UI), routed through host network egress. + +**Architecture:** New capability mirrors `trace_commons.profile_token`: consent +gate → enrollment pre-check via `resolve_trace_credentials` → `HostEgressContributionSink` +→ a new `ironclaw_reborn_traces` sink call that POSTs `/v1/account/login-links` +with the per-user subject and returns the `{ account_id, url }` response. The raw +URL is returned to the user (it is a one-time, user-facing link, not a stored +bearer credential). + +**Tech Stack:** Rust, async_trait, serde_json. Spec: +`docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md`. + +## Global Constraints + +- No `.unwrap()`/`.expect()` in production code. +- Zero clippy warnings. +- The mint POST MUST route through `RuntimeHttpEgress` (host egress) — never a + direct client (mirrors `dispatch_onboard`/`dispatch_profile_token`). +- Consent gate is the hard fail-closed boundary: no network call unless + `confirmed == true`. +- Capability id: `builtin.trace_commons.account_login_link`. Schema file: + `schemas/builtin/trace_commons-account_login_link.input.v1.json`. +- Tests: `cargo test -p ironclaw_reborn_traces` (sink call) and + `cargo test --package ironclaw_host_runtime --test trace_commons_dispatch_e2e` + (dispatch caller). + +--- + +### Task 1: `mint_account_login_link_for_scope_via_sink` in reborn_traces + +**Files:** +- Modify: `crates/ironclaw_reborn_traces/src/contribution.rs` (add near + `mint_profile_attribution_token_for_scope_via_sink` at line 5546) +- Test: same file, test module (mirror the mock-issuer pattern at 12558). + +**Interfaces:** +- Consumes: `ContributionHttpSink` (line 5110), `resolve_trace_credentials` + (Slice 1), `read_trace_policy_for_scope`. +- Produces: + ```rust + pub struct AccountLoginLink { pub account_id: String, pub url: String } + + pub async fn mint_account_login_link_via_sink( + tenant_id: &str, + user_id: &str, + sink: &dyn ContributionHttpSink, + ) -> anyhow::Result; + ``` + POSTs `{ "subject": }` (subject omitted when `None`) to + `/v1/account/login-links` with the per-user bearer, parses + `{ account_id, url }`. The login-links URL is derived from the policy's + issuer/ingest origin (same origin family as the upload-claim issuer). + +- [ ] **Step 1: Write the failing test** + +```rust +#[tokio::test] +async fn mint_account_login_link_posts_subject_and_returns_url() { + use std::sync::{Arc, Mutex}; + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + let app = axum::Router::new().route( + "/v1/account/login-links", + axum::routing::post(move |axum::Json(b): axum::Json| { + let cap = cap.clone(); + async move { + cap.lock().unwrap().push(b); + axum::Json(serde_json::json!({ + "account_id": "11111111-1111-1111-1111-111111111111", + "url": "/account/login?code=abc" + })) + } + }), + ); + 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; }); + + // Instance-enrolled policy at scope None so the resolver yields a subject. + let base = tempfile::tempdir().unwrap(); + // (isolate base dir as other tests in this module do) + let mut policy = StandingTraceContributionPolicy::default(); + policy.enabled = true; + policy.auth_mode = TraceUploadAuthMode::DeviceKey; + policy.upload_token_issuer_url = Some(format!("http://{addr}/v1/trace-upload-claim")); + policy.upload_token_issuer_allowed_hosts = std::collections::BTreeSet::from(["127.0.0.1".to_string()]); + policy.upload_token_tenant_id = Some("tenant-dev".to_string()); + write_trace_policy_for_scope(None, &policy).unwrap(); + // device key + bearer prerequisites mirror fetch-claim tests. + + let sink = TestContributionSink::new(); // crate test sink that hits the mock + let link = mint_account_login_link_via_sink("tenant-dev", "alice", &sink).await.unwrap(); + assert_eq!(link.url, "/account/login?code=abc"); + let bodies = captured.lock().unwrap(); + assert_eq!(bodies[0]["subject"], local_pseudonymous_contributor_id(&trace_scope_key("tenant-dev","alice"))); +} +``` + +> Reuse whatever in-crate test `ContributionHttpSink` impl the existing +> sink-based tests use (search the test module for an impl of +> `ContributionHttpSink`; the profile-set/profile-token sink tests have one). If +> none exists, add a minimal test sink that forwards to a reqwest call against +> the mock — mirror how `set_community_profile_for_scope_via_sink` is tested. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p ironclaw_reborn_traces mint_account_login_link_posts_subject` +Expected: FAIL — function not found. + +- [ ] **Step 3: Implement the sink call** + +Add (mirroring `set_community_profile_for_scope_inner`'s URL/derivation + the +`ContributionHttpRequest` shape): + +```rust +/// One-time browser login link for the Trace Commons contributor account. +#[derive(Debug, Clone)] +pub struct AccountLoginLink { + pub account_id: String, + pub url: String, +} + +/// Mint a one-time account login link for `(tenant, user)`. POSTs the per-user +/// subject (when the instance model is active) to `/v1/account/login-links` so +/// the server resolves the correct per-user account. Routes through the caller- +/// supplied sink (host egress on the agent path). +pub async fn mint_account_login_link_via_sink( + tenant_id: &str, + user_id: &str, + sink: &dyn ContributionHttpSink, +) -> anyhow::Result { + let resolution = resolve_trace_credentials(tenant_id, user_id)? + .ok_or_else(|| anyhow::anyhow!("not enrolled in Trace Commons"))?; + let context = TraceUploadClaimContext::for_account(resolution.subject.clone()); + // Mint the per-user bearer the same way submission does. + let provider = DefaultTraceUploadCredentialProvider; + let bearer = provider + .bearer_token(&resolution.policy, &context, false) + .await?; + let url = account_login_links_url(&resolution.policy)?; // origin + /v1/account/login-links + let body = match &resolution.subject { + Some(s) => serde_json::json!({ "subject": s }), + None => serde_json::json!({}), + }; + let response = sink + .execute(ContributionHttpRequest { + method: ContributionHttpMethod::Post, + url, + bearer_token: Some(bearer), + json_body: Some(serde_json::to_vec(&body)?), + response_body_limit: TRACE_UPLOAD_CLAIM_MAX_RESPONSE_BYTES as u64, + timeout_ms: 10_000, + }) + .await + .map_err(|e| anyhow::anyhow!("login-link request failed: {e}"))?; + anyhow::ensure!( + (200..300).contains(&response.status), + "login-link request returned status {}", + response.status + ); + let parsed: serde_json::Value = + serde_json::from_slice(&response.body).context("login-link response was not valid JSON")?; + let account_id = parsed["account_id"].as_str().unwrap_or_default().to_string(); + let link = parsed["url"].as_str().unwrap_or_default().to_string(); + anyhow::ensure!(!link.is_empty(), "login-link response missing url"); + Ok(AccountLoginLink { account_id, url: link }) +} +``` + +Add helpers: `account_login_links_url(policy)` derives the origin from +`policy.upload_token_issuer_url` (strip `/v1/trace-upload-claim`, append +`/v1/account/login-links`); `TraceUploadClaimContext::for_account(subject)` +builds a context with no trace/submission id and the given subject (account-mgmt +scope). If a context constructor without an envelope doesn't exist, add a small +one mirroring `for_envelope` but with `trace_id/submission_id = None`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p ironclaw_reborn_traces mint_account_login_link_posts_subject` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/contribution.rs +git commit -m "feat(traces): mint_account_login_link_via_sink (POST /v1/account/login-links)" +``` + +--- + +### Task 2: First-party capability `trace_commons.account_login_link` + +**Files:** +- Modify: `crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs` + (add const + manifest + `dispatch_account_login_link`, mirroring + `dispatch_profile_token` at 657 and `profile_token_manifest`) +- Modify: `crates/ironclaw_host_runtime/src/first_party_tools/mod.rs` + (register manifest at ~177, handler at ~301, dispatch arm at ~414) +- Modify: `crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs` + (add input schema arm near 217) +- Test: `crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs` + +**Interfaces:** +- Consumes: `mint_account_login_link_via_sink` (Task 1), `HostEgressContributionSink` + (existing, line 350), `resolve_trace_credentials`. +- Produces: capability `builtin.trace_commons.account_login_link`, model-visible, + `PermissionMode::Ask`, effects `[Network, ExternalWrite]`. Output: + `{ "minted": true, "url": "...", "message": "..." }` or a sanitized error. + +- [ ] **Step 1: Write the failing test** + +Mirror `onboard_then_status_through_dispatch` (line 369). After onboarding the +scope, invoke the new capability with `confirmed=true` against a mock that serves +`/v1/account/login-links`, and assert the returned `url`: + +```rust +#[tokio::test] +async fn account_login_link_through_dispatch() { + let _base = setup_base_dir(); + // onboard first (reuse the onboard mock flow from onboard_then_status), then: + let (addr, _received) = spawn_mock_account_login(/* serves {account_id,url} */).await; + // point the onboarded policy's issuer origin at `addr` (the onboard mock can + // serve both /v1/trace-upload-claim and /v1/account/login-links). + let result = invoke_with_context( + &rt, + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + json!({ "confirmed": true }), + execution_context_with_network("user_x","caller_x", + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, allow_all_network_policy()), + ).await.expect("dispatch ok"); + assert_eq!(result["minted"], json!(true)); + assert!(result["url"].as_str().unwrap().contains("/account/login?code=")); +} + +#[tokio::test] +async fn account_login_link_requires_consent() { + let _base = setup_base_dir(); + let rt = runtime(); + let result = invoke_with_context( + &rt, TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, json!({}), + execution_context_read_only("u","c", TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID), + ).await.expect("dispatch ok"); + assert_eq!(result["consent_required"], json!(true)); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --package ironclaw_host_runtime --test trace_commons_dispatch_e2e account_login_link` +Expected: FAIL — unknown capability id. + +- [ ] **Step 3: Implement the capability** + +In `trace_commons.rs`, add the const next to the others (line 55): + +```rust +pub const TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID: &str = + "builtin.trace_commons.account_login_link"; +``` + +Add a manifest builder mirroring `profile_token_manifest`: + +```rust +pub(super) fn account_login_link_manifest() -> Result { + first_party_capability_manifest( + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + "Mint a one-time Trace Commons browser login link so the user can manage \ + their contributor account/profile in the web UI. Consent-gated: only call \ + with confirmed=true after the user explicitly asks. Routes through host \ + network egress.", + vec![EffectKind::Network, EffectKind::ExternalWrite], + PermissionMode::Ask, + resource_profile(), // reuse the same helper profile_token uses + ) +} +``` + +Add the dispatch handler mirroring `dispatch_profile_token` (consent gate → +enrollment pre-check via `resolve_trace_credentials` → egress sink → call): + +```rust +pub(super) async fn dispatch_account_login_link( + request: &FirstPartyCapabilityRequest, +) -> Result { + let confirmed = request.input.get("confirmed").and_then(Value::as_bool).unwrap_or(false); + if !confirmed { + return Ok(json!({ + "minted": false, + "consent_required": true, + "message": "Minting a Trace Commons login link opens a browser session to \ + manage the user's contributor account. Confirm with the user, then call again \ + with confirmed=true." + })); + } + let tenant_id = request.scope.tenant_id.as_str(); + let user_id = request.scope.user_id.as_str(); + match ironclaw_reborn_traces::contribution::resolve_trace_credentials(tenant_id, user_id) { + Ok(Some(r)) if r.policy.enabled => {} + Ok(_) => return Ok(account_login_link_error_value("not enrolled in Trace Commons".into())), + Err(e) => return Ok(account_login_link_error_value(e.to_string())), + } + let egress = match request.services.runtime_http_egress.as_ref() { + Some(e) => e.clone(), + None => return Err(FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::NetworkDenied)), + }; + let sink = HostEgressContributionSink { + egress, + scope: request.scope.clone(), + capability_id: request.capability_id.clone(), + }; + match ironclaw_reborn_traces::contribution::mint_account_login_link_via_sink( + tenant_id, user_id, &sink, + ).await { + Ok(link) => Ok(json!({ + "minted": true, + "url": link.url, + "message": "Open this one-time link in a browser to manage your Trace Commons account. \ + It expires shortly and can be used once." + })), + Err(e) => Ok(account_login_link_error_value(e.to_string())), + } +} + +fn account_login_link_error_value(error: String) -> Value { + let (code, message) = if error.contains("not enrolled") { + ("NotEnrolled", "Trace Commons enrollment was not found for this user.") + } else { + ("LoginLinkMintFailed", "Could not mint a Trace Commons login link. Check enrollment and retry.") + }; + json!({ "minted": false, "error_code": code, "message": message }) +} +``` + +In `mod.rs`: add `trace_commons::account_login_link_manifest()?` to the manifest +list (~177), an `insert_handler` for the new id (~301), and the dispatch arm: + +```rust + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID => { + (trace_commons::dispatch_account_login_link(&request).await?, None) + } +``` + +(Import the new const alongside the existing `TRACE_COMMONS_*` ids.) + +In `schemas.rs`, add the arm near 217: + +```rust +"schemas/builtin/trace_commons-account_login_link.input.v1.json" => json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "description": "Must be true only after the user explicitly asked to open a Trace Commons account/profile login link in this conversation (default: false)" + } + }, + "additionalProperties": false +}), +``` + +- [ ] **Step 4: Run tests to verify they pass + clippy** + +Run: `cargo test --package ironclaw_host_runtime --test trace_commons_dispatch_e2e account_login_link` +Then: `cargo clippy --all --tests` +Expected: PASS; zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_host_runtime/src/first_party_tools/ +git commit -m "feat(traces): trace_commons.account_login_link first-party capability" +``` + +--- + +## Self-Review + +- **Spec coverage:** Spec §"User profiles — login-link capability" → Task 1 (sink + call posting subject to `/v1/account/login-links`) + Task 2 (consent-gated, + egress-routed, model-visible capability). Consent gate (hard boundary) → + Task 2 `account_login_link_requires_consent`. Subject carried → Task 1 test + asserts the posted `subject`. +- **Placeholder scan:** Test sink / mock-server helpers are specified by + reference to the exact existing patterns (`set_community_profile_for_scope_via_sink` + test, `spawn_mock_issuer`, `onboard_then_status_through_dispatch`). New helpers + (`account_login_links_url`, `for_account`) have concrete derivation rules. No + open TODOs. +- **Type consistency:** `AccountLoginLink { account_id, url }` (Task 1) consumed + in Task 2; capability id const name `TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID` + identical across trace_commons.rs/mod.rs/tests; `HostEgressContributionSink` + fields `{ egress, scope, capability_id }` match the existing struct (line 350). diff --git a/docs/superpowers/plans/2026-06-25-trace-commons-slice4-trace-inspection.md b/docs/superpowers/plans/2026-06-25-trace-commons-slice4-trace-inspection.md new file mode 100644 index 00000000000..a92203ce648 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-trace-commons-slice4-trace-inspection.md @@ -0,0 +1,406 @@ +# Trace Commons Slice 4 — Trace inspection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Also read first:** the `reborn-feature` skill — this slice crosses +> reborn_traces → product_workflow facade → webui_v2 handler → frontend, exactly +> the layering that skill maps. Wire it in one pass per that guidance. + +> **Target repo:** `ironclaw`. **Depends on Slice 0** (server `/v1/account/traces*`) +> and **Slices 1–2** (resolver + per-user bearer/subject). Until Slice 0 ships, +> the fetch returns the server's empty/error and the UI shows the zero-state. + +**Goal:** Let a user inspect their *submitted* Trace Commons traces (list + +scrubbed content) from the IronClaw web UI, fetched per-user from the server via +the instance device bearer, complementing the existing local held-trace review. + +**Architecture:** Mirror the existing read-only credit surface end to end: +`TraceClientHost` gains an account-traces fetch (per-user bearer) → the +`product_workflow` services facade gains a `trace_account_traces(caller)` method → +the `webui_v2` handler `GET /api/webchat/v2/traces/account` returns it → a React +hook/component renders it under the Trace Commons settings tab. Scope is always +derived from the authenticated caller, never the request. + +**Tech Stack:** Rust (axum, tokio), React (htm/preact + @tanstack/react-query). +Spec: `docs/superpowers/specs/2026-06-25-trace-commons-instance-enrollment-profiles-inspection-design.md`. + +## Global Constraints + +- Scope derived from the authenticated caller only — no scope/user input from the + request (matches `trace_credits`). +- Read-only projection of server state; no raw bearer or device-key material in + any response; sanitize errors at the HTTP boundary (`WebUiV2HttpError`). +- No `.unwrap()`/`.expect()` in production code; zero clippy warnings. +- Rust tests: `cargo test -p ironclaw_reborn_traces`, and + `cargo test -p ironclaw_webui_v2 --features webui-v2-beta`. Frontend assets are + embedded at compile time (`assets.rs`); no JS test harness — verify via the + handler contract test + manual. + +--- + +### Task 1: `fetch_account_traces` in reborn_traces (per-user list) + +**Files:** +- Modify: `crates/ironclaw_reborn_traces/src/client.rs` (add an account-traces + fetch alongside the existing remote sync) and/or `contribution.rs` for the + request shaping (mirror `mint_account_login_link_via_sink` from Slice 3). +- Test: the owning file's test module (mock-issuer pattern). + +**Interfaces:** +- Consumes: `resolve_trace_credentials` (Slice 1), `ContributionHttpSink`, + `DefaultTraceUploadCredentialProvider` (per-user bearer). +- Produces: + ```rust + #[derive(Debug, Clone, serde::Serialize)] + pub struct AccountTraceItem { + pub submission_id: String, + pub status: String, + pub credit_points_pending: f32, + pub credit_points_final: Option, + pub received_at: Option, + } + + pub async fn fetch_account_traces_via_sink( + tenant_id: &str, + user_id: &str, + limit: Option, + sink: &dyn ContributionHttpSink, + ) -> anyhow::Result>; + ``` + GETs `/v1/account/traces?limit=N` with the per-user bearer; maps the + server's list items to `AccountTraceItem` (only the sanitized projection + fields the UI needs). `ContributionHttpMethod` has no `Get`; add a `Get` + variant (and map it in `HostEgressContributionSink`) OR perform the GET via the + existing hardened reqwest path used by `read_local_records_for_scope`/sync. + Prefer extending `ContributionHttpMethod` with `Get` so the host-egress path is + reused — see Step 3. + +- [ ] **Step 1: Write the failing test** + +```rust +#[tokio::test] +async fn fetch_account_traces_returns_user_submissions() { + let app = axum::Router::new().route( + "/v1/account/traces", + axum::routing::get(|| async { + axum::Json(serde_json::json!([ + { "submission_id": "s1", "status": "accepted", + "credit_points_pending": 1.0, "credit_points_final": 1.0, + "received_at": "2026-06-25T00:00:00Z" } + ])) + }), + ); + 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; }); + + // instance-enrolled policy at scope None (see Slice 3 Task 1 setup), device key promoted. + // ...write_trace_policy_for_scope(None, &policy) with issuer origin = addr... + + let sink = TestContributionSink::new(); + let items = fetch_account_traces_via_sink("tenant-dev", "alice", Some(50), &sink).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].submission_id, "s1"); + assert_eq!(items[0].status, "accepted"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p ironclaw_reborn_traces fetch_account_traces_returns_user_submissions` +Expected: FAIL — function not found. + +- [ ] **Step 3: Implement** + +Add `Get` to `ContributionHttpMethod` (contribution.rs:5118) and map it in the +host sink (`HostEgressContributionSink::execute`, first_party_tools/trace_commons.rs:367): + +```rust +// in ContributionHttpMethod + Get, +// in HostEgressContributionSink::execute match + ContributionHttpMethod::Get => NetworkMethod::Get, +``` + +Then implement `fetch_account_traces_via_sink` mirroring +`mint_account_login_link_via_sink` (Slice 3 Task 1): resolve credentials, mint the +per-user bearer, build `/v1/account/traces?limit=N` via the same origin +helper, `sink.execute` a `Get`, parse the JSON array into `Vec` +(use serde derive on `AccountTraceItem`; unknown server fields ignored). Empty/ +non-2xx → return `Ok(vec![])` for unenrolled, `Err` for transport failures +(match the credit endpoint's lenient "zero-state, not error" behavior for the +unenrolled case). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p ironclaw_reborn_traces fetch_account_traces_returns_user_submissions` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_reborn_traces/src/ +git commit -m "feat(traces): fetch_account_traces_via_sink (GET /v1/account/traces, per-user)" +``` + +--- + +### Task 2: product_workflow facade method + +**Files:** +- Modify: the services facade trait + default impl that backs + `state.services().trace_credits(...)` (in `crates/ironclaw_product_workflow/`; + find it via `grep -rn "fn trace_credits" crates/ironclaw_product_workflow`). +- Test: the facade's own tests if present; otherwise covered by Task 3's contract test. + +**Interfaces:** +- Consumes: `fetch_account_traces_via_sink` (Task 1). +- Produces: a facade method + ```rust + async fn trace_account_traces( + &self, + caller: WebUiAuthenticatedCaller, + ) -> Result; + ``` + deriving `(tenant_id, user_id)` from `caller`, calling the reborn_traces fetch + through the host egress sink the facade already uses for trace operations, and + returning a sanitized `RebornAccountTracesResponse { enrolled: bool, traces: Vec }`. + +- [ ] **Step 1: Write the failing test (or rely on Task 3)** + +If the facade trait has a `StubServices` default (used by the contract test), +add a default `trace_account_traces` returning the unenrolled zero-state +(`{ enrolled: false, traces: [] }`) so the contract test in Task 3 compiles +against the trait. Write the contract assertion in Task 3. + +- [ ] **Step 2: Implement the facade method + types** + +Define the wire types next to the existing `RebornTraceCreditsResponse`: + +```rust +#[derive(Debug, Clone, serde::Serialize)] +pub struct RebornAccountTrace { + pub submission_id: String, + pub status: String, + pub pending_credit: f32, + pub final_credit: Option, + pub received_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct RebornAccountTracesResponse { + pub enrolled: bool, + pub traces: Vec, +} +``` + +Implement the real (non-stub) method to derive `(tenant, user)` from `caller`, +call `fetch_account_traces_via_sink` via the facade's host-egress sink, map +`AccountTraceItem` → `RebornAccountTrace`, and set `enrolled` from +`resolve_trace_credentials(...).is_some()`. Add the matching default to +`StubServices` returning `{ enrolled: false, traces: vec![] }`. + +- [ ] **Step 3: Build** + +Run: `cargo build -p ironclaw_product_workflow` +Expected: compiles. + +- [ ] **Step 4: Commit** + +```bash +git add crates/ironclaw_product_workflow/ +git commit -m "feat(reborn): trace_account_traces facade method + wire types" +``` + +--- + +### Task 3: webui_v2 handler + route + contract test + +**Files:** +- Modify: `crates/ironclaw_webui_v2/src/handlers.rs` (add `trace_account_traces` + handler mirroring `trace_credits` at 671-686) +- Modify: the v2 router registration (find via + `grep -rn "traces/credit" crates/ironclaw_webui_v2/src`) to add the new route +- Test: `crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs` (mirror + `trace_credits_returns_caller_scoped_unenrolled_zero_state` at 1731) + +**Interfaces:** +- Consumes: facade `trace_account_traces` (Task 2). +- Produces: `GET /api/webchat/v2/traces/account` → + `Json`. + +- [ ] **Step 1: Write the failing contract test** + +```rust +#[tokio::test] +async fn trace_account_traces_returns_caller_scoped_unenrolled_zero_state() { + let user_id = format!( + "webui-v2-account-traces-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + ); + let caller = WebUiAuthenticatedCaller::new( + TenantId::new("tenant-alpha").unwrap(), + UserId::new(user_id.as_str()).unwrap(), + None, None, + ); + let router = webui_v2_router(WebUiV2State::new( + Arc::new(StubServices::default()), + DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER, + )) + .layer(axum::Extension(caller)) + .layer(axum::Extension(WebUiV2Capabilities::default())); + + let response = router.oneshot( + Request::builder().method(Method::GET) + .uri("/api/webchat/v2/traces/account") + .body(Body::empty()).unwrap(), + ).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = read_json(response).await; + assert_eq!(body["enrolled"], false); + assert_eq!(body["traces"].as_array().unwrap().len(), 0); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p ironclaw_webui_v2 --features webui-v2-beta trace_account_traces_returns_caller_scoped` +Expected: FAIL — 404 (route not registered). + +- [ ] **Step 3: Implement handler + route** + +In `handlers.rs` (after `trace_credits`): + +```rust +/// `GET /api/webchat/v2/traces/account` +/// +/// Read-only list of the authenticated caller's submitted Trace Commons traces, +/// fetched per-user from the server. Scope is derived from the caller; no input +/// is accepted. Unenrolled callers receive the zero-state, not an error. +pub async fn trace_account_traces( + State(state): State, + Extension(caller): Extension, +) -> Result, WebUiV2HttpError> { + let response = state.services().trace_account_traces(caller).await?; + Ok(Json(response)) +} +``` + +Register the route next to `traces/credit` (mirror its `.route(...)` line): + +```rust + .route("/api/webchat/v2/traces/account", get(trace_account_traces)) +``` + +(Import `RebornAccountTracesResponse` and `get` as needed.) + +- [ ] **Step 4: Run test to verify it passes + clippy** + +Run: `cargo test -p ironclaw_webui_v2 --features webui-v2-beta trace_account_traces_returns_caller_scoped` +Then: `cargo clippy --all --tests` +Expected: PASS; zero warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_webui_v2/ +git commit -m "feat(reborn): GET /api/webchat/v2/traces/account handler + contract test" +``` + +--- + +### Task 4: Frontend — fetch + render submitted traces + +**Files:** +- Modify: `crates/ironclaw_webui_v2_static/static/js/pages/settings/lib/settings-api.js` + (add `fetchAccountTraces`, mirror `fetchTraceCredits` at 120-133) +- Create: `crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useAccountTraces.js` + (mirror `useTraceCredits.js`) +- Modify: the Trace Commons settings tab component to render the list (find via + `grep -rln "useTraceCredits" crates/ironclaw_webui_v2_static/static/js`) +- No `assets.rs` edit needed for modified files; **new** JS files are picked up by + `build.rs` automatically (assets are generated from the `static/` tree at + compile time — see `assets.rs` header). + +**Interfaces:** +- Consumes: `GET /api/webchat/v2/traces/account` (Task 3). +- Produces: `useAccountTraces()` hook + a rendered list under the traces tab. + +- [ ] **Step 1: Add the API function** + +In `settings-api.js`: + +```javascript +// Submitted Trace Commons traces for the authenticated caller (read-only, +// server-scoped). Mirrors fetchTraceCredits. +export function fetchAccountTraces() { + return apiFetch("/api/webchat/v2/traces/account"); +} +``` + +- [ ] **Step 2: Add the hook** + +Create `useAccountTraces.js`: + +```javascript +import { useQuery } from "@tanstack/react-query"; +import { fetchAccountTraces } from "../lib/settings-api.js"; + +export function useAccountTraces() { + const query = useQuery({ + queryKey: ["account-traces"], + queryFn: fetchAccountTraces, + refetchInterval: 300_000, + refetchOnWindowFocus: true, + staleTime: 60_000, + }); + return { traces: query.data?.traces || [], enrolled: !!query.data?.enrolled, query }; +} +``` + +- [ ] **Step 3: Render in the traces settings tab** + +In the Trace Commons settings tab component, import `useAccountTraces` and, when +`enrolled`, render a list of `traces` (submission id, status, pending/final +credit, received_at). Mirror the existing credit/holds rendering markup style in +that file. Show nothing extra when `!enrolled`. + +- [ ] **Step 4: Verify build embeds the assets** + +Run: `cargo build -p ironclaw_webui_v2_static` +Expected: compiles (new JS files embedded by `build.rs`). Optionally run the app +(`/run` skill) and open Settings → Traces to confirm the list renders. + +- [ ] **Step 5: Commit** + +```bash +git add crates/ironclaw_webui_v2_static/static/js/ +git commit -m "feat(reborn-ui): render submitted Trace Commons traces in settings" +``` + +--- + +## Self-Review + +- **Spec coverage:** Spec §"Trace inspection" (list/detail/scrubbed content) → + Task 1 (per-user `fetch_account_traces_via_sink`), Task 2 (facade), Task 3 + (v2 endpoint), Task 4 (UI). This slice ships the **list** surface end to end; + detail/content (`/{id}` and `/{id}/content`) follow the identical pattern and + are called out as a fast-follow (see note). "Scope from caller only" → Task 3 + contract test + handler signature. "Read-only projection, no credential leak" + → `AccountTraceItem`/`RebornAccountTrace` carry only sanitized fields. +- **Scope note (no silent cap):** This plan ships the trace *list*. Detail + (`GET /v1/account/traces/{id}`) and scrubbed content + (`GET /v1/account/traces/{id}/content`) reuse Tasks 1–4 verbatim with the id in + the path and a content-type passthrough; add them as Task 5/6 in a follow-up + rather than expanding this slice. Flagged here so coverage is explicit. +- **Placeholder scan:** Component/file locations are given by grep recipe against + the exact existing symbols (`useTraceCredits`, `fetchTraceCredits`, + `trace_credits`). No "add validation"/"TODO" steps. +- **Type consistency:** `AccountTraceItem` (Task 1) → mapped to `RebornAccountTrace` + (Task 2) → serialized in `RebornAccountTracesResponse` (Tasks 2/3) → consumed by + `useAccountTraces` (Task 4) reading `data.traces` / `data.enrolled`, matching + the response field names. `ContributionHttpMethod::Get` added in Task 1 and + mapped in the host sink in the same task. From e48fade35809bb687ea2f68aacf6659e1fa887a6 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 12:16:26 -0700 Subject: [PATCH 04/46] feat(traces): trace-credential resolver (personal invite wins, instance fallback w/ subject) --- .../src/contribution.rs | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 042861894fa..e8967f38f5d 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -4075,6 +4075,86 @@ pub fn write_trace_policy_for_scope( write_json_file(&trace_policy_path(scope), policy, "trace policy") } +/// Resolved Trace Commons credentials for a (tenant, user): which local-state +/// scope to use and the per-user subject (if any) to send to the server. +#[derive(Debug, Clone, PartialEq)] +pub struct TraceCredentialResolution { + /// The scope string whose local state (policy, device key, credits) to use. + pub state_scope: String, + /// Per-user subject to send in upload-claim / login-link requests. + /// `None` for the personal-invite model (device key already 1:1 with user). + pub subject: Option, + /// The resolved enrollment policy. + pub policy: StandingTraceContributionPolicy, +} + +/// Inner implementation that reads policies relative to an explicit base dir. +/// Used by `resolve_trace_credentials` (which supplies the real base) and by +/// tests (which supply an isolated tempdir). +fn resolve_trace_credentials_at( + base_dir: &std::path::Path, + tenant_id: &str, + user_id: &str, +) -> anyhow::Result> { + let scope = trace_scope_key(tenant_id, user_id); + let contributions_base = base_dir.join("trace_contributions"); + + let read_policy = |scope_opt: Option<&str>| -> anyhow::Result { + let path = match scope_opt { + Some(s) if !s.trim().is_empty() => contributions_base + .join("users") + .join(scope_hash(s)) + .join("policy.json"), + _ => contributions_base.join("policy.json"), + }; + if !path.exists() { + return Ok(StandingTraceContributionPolicy::default()); + } + let body = std::fs::read_to_string(&path).map_err(|e| { + anyhow::anyhow!("failed to read trace policy {}: {}", path.display(), e) + })?; + serde_json::from_str(&body).map_err(|e| { + anyhow::anyhow!("failed to parse trace policy {}: {}", path.display(), e) + }) + }; + + let personal = read_policy(Some(scope.as_str())) + .map_err(|e| anyhow::anyhow!("failed to read personal trace policy: {e}"))?; + if personal.enabled { + return Ok(Some(TraceCredentialResolution { + state_scope: scope, + subject: None, + policy: personal, + })); + } + + let instance = read_policy(None) + .map_err(|e| anyhow::anyhow!("failed to read instance trace policy: {e}"))?; + if instance.enabled { + return Ok(Some(TraceCredentialResolution { + subject: Some(local_pseudonymous_contributor_id(&scope)), + state_scope: scope, + policy: instance, + })); + } + + Ok(None) +} + +/// 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 enabled. +pub fn resolve_trace_credentials( + tenant_id: &str, + user_id: &str, +) -> anyhow::Result> { + resolve_trace_credentials_at( + ironclaw_common::paths::ironclaw_base_dir().as_path(), + tenant_id, + user_id, + ) +} + pub fn mark_trace_credit_notice_due_for_scope( scope: Option<&str>, ) -> anyhow::Result> { @@ -14455,4 +14535,78 @@ mod tests { .expect_err("oversized bio must be rejected"); assert!(error.to_string().contains("at most 280 bytes")); } + + // --- resolve_trace_credentials tests --- + // Isolation: each test uses its own tempdir passed to the private + // `resolve_trace_credentials_at` core, so tests are fully isolated from + // the global IRONCLAW_BASE_DIR and from each other (no shared state, + // no cleanup needed). The public `resolve_trace_credentials` is a thin + // wrapper that supplies the real base dir — the core logic is tested here. + + fn write_policy_at( + base: &std::path::Path, + scope: Option<&str>, + policy: &StandingTraceContributionPolicy, + ) { + let contributions = base.join("trace_contributions"); + let path = match scope { + Some(s) if !s.trim().is_empty() => contributions + .join("users") + .join(super::scope_hash(s)) + .join("policy.json"), + _ => contributions.join("policy.json"), + }; + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_string(policy).unwrap()).unwrap(); + } + + #[test] + fn resolver_prefers_personal_invite_enrollment_with_no_subject() { + let dir = tempfile::tempdir().unwrap(); + let scope = trace_scope_key("tenant-a", "alice"); + let personal = StandingTraceContributionPolicy { + enabled: true, + ..Default::default() + }; + write_policy_at(dir.path(), Some(scope.as_str()), &personal); + + let r = resolve_trace_credentials_at(dir.path(), "tenant-a", "alice") + .unwrap() + .unwrap(); + assert_eq!(r.state_scope, scope); + assert_eq!(r.subject, None, "personal invite carries no subject"); + assert!(r.policy.enabled); + } + + #[test] + fn resolver_falls_back_to_instance_enrollment_with_per_user_subject() { + let dir = tempfile::tempdir().unwrap(); + // No personal policy; only the instance-level (scope None) policy. + let instance = StandingTraceContributionPolicy { + enabled: true, + ..Default::default() + }; + write_policy_at(dir.path(), None, &instance); + + let r = resolve_trace_credentials_at(dir.path(), "tenant-a", "alice") + .unwrap() + .unwrap(); + let expected_scope = trace_scope_key("tenant-a", "alice"); + assert_eq!( + r.subject, + Some(local_pseudonymous_contributor_id(&expected_scope)) + ); + assert!(r.policy.enabled); + } + + #[test] + fn resolver_returns_none_when_unenrolled() { + let dir = tempfile::tempdir().unwrap(); + // Empty dir — no policy files at all. + assert!( + resolve_trace_credentials_at(dir.path(), "tenant-a", "alice") + .unwrap() + .is_none() + ); + } } From 4a1ec039976203586e93ce4de8d982ecca74c977 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 12:32:38 -0700 Subject: [PATCH 05/46] refactor(traces): single dir-parameterized policy-path site (remove resolver duplication) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract `trace_contribution_dir_for_scope_at`, `trace_policy_path_at`, `read_trace_policy_for_scope_at`, and `write_trace_policy_for_scope_at` as the canonical base-dir-parameterized path helpers. All public functions (`trace_contribution_dir_for_scope`, `read_trace_policy_for_scope`, `write_trace_policy_for_scope`) now delegate to the `_at` variants with `ironclaw_base_dir()` — signatures unchanged. The inline `read_policy` closure in `resolve_trace_credentials_at` that re-implemented path layout is deleted; it now calls `read_trace_policy_for_scope_at` directly. The test `write_policy_at` helper's bespoke path construction is replaced with a call to `write_trace_policy_for_scope_at`. The now-dead `trace_policy_path` function is removed. Path layout is encoded in exactly one place. Co-Authored-By: Claude Sonnet 4.6 --- .../src/contribution.rs | 74 +++++++++---------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index e8967f38f5d..b05afffa26b 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -3988,14 +3988,20 @@ fn is_zero_u32(value: &u32) -> bool { *value == 0 } -pub fn trace_contribution_dir_for_scope(scope: Option<&str>) -> PathBuf { - let base = ironclaw_common::paths::ironclaw_base_dir().join("trace_contributions"); +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() => base.join("users").join(scope_hash(scope)), - _ => base, + Some(scope) if !scope.trim().is_empty() => { + contributions.join("users").join(scope_hash(scope)) + } + _ => contributions, } } +pub fn trace_contribution_dir_for_scope(scope: Option<&str>) -> PathBuf { + trace_contribution_dir_for_scope_at(&ironclaw_common::paths::ironclaw_base_dir(), scope) +} + /// Canonical per-scope key for Trace Commons local state (policy, device keys, /// credits, profile tokens). /// @@ -4055,10 +4061,11 @@ fn lock_trace_scope_for_mutation_blocking(scope: Option<&str>) -> OwnedMutexGuar } } -pub fn read_trace_policy_for_scope( +fn read_trace_policy_for_scope_at( + base: &std::path::Path, scope: Option<&str>, ) -> anyhow::Result { - let path = trace_policy_path(scope); + let path = trace_policy_path_at(base, scope); if !path.exists() { return Ok(StandingTraceContributionPolicy::default()); } @@ -4068,11 +4075,25 @@ pub fn read_trace_policy_for_scope( .map_err(|e| anyhow::anyhow!("failed to parse trace policy {}: {}", path.display(), e)) } +pub fn read_trace_policy_for_scope( + scope: Option<&str>, +) -> anyhow::Result { + read_trace_policy_for_scope_at(&ironclaw_common::paths::ironclaw_base_dir(), scope) +} + +fn write_trace_policy_for_scope_at( + base: &std::path::Path, + scope: Option<&str>, + policy: &StandingTraceContributionPolicy, +) -> anyhow::Result<()> { + write_json_file(&trace_policy_path_at(base, scope), policy, "trace policy") +} + pub fn write_trace_policy_for_scope( scope: Option<&str>, policy: &StandingTraceContributionPolicy, ) -> anyhow::Result<()> { - write_json_file(&trace_policy_path(scope), policy, "trace policy") + write_trace_policy_for_scope_at(&ironclaw_common::paths::ironclaw_base_dir(), scope, policy) } /// Resolved Trace Commons credentials for a (tenant, user): which local-state @@ -4097,28 +4118,8 @@ fn resolve_trace_credentials_at( user_id: &str, ) -> anyhow::Result> { let scope = trace_scope_key(tenant_id, user_id); - let contributions_base = base_dir.join("trace_contributions"); - - let read_policy = |scope_opt: Option<&str>| -> anyhow::Result { - let path = match scope_opt { - Some(s) if !s.trim().is_empty() => contributions_base - .join("users") - .join(scope_hash(s)) - .join("policy.json"), - _ => contributions_base.join("policy.json"), - }; - if !path.exists() { - return Ok(StandingTraceContributionPolicy::default()); - } - let body = std::fs::read_to_string(&path).map_err(|e| { - anyhow::anyhow!("failed to read trace policy {}: {}", path.display(), e) - })?; - serde_json::from_str(&body).map_err(|e| { - anyhow::anyhow!("failed to parse trace policy {}: {}", path.display(), e) - }) - }; - let personal = read_policy(Some(scope.as_str())) + let personal = read_trace_policy_for_scope_at(base_dir, Some(scope.as_str())) .map_err(|e| anyhow::anyhow!("failed to read personal trace policy: {e}"))?; if personal.enabled { return Ok(Some(TraceCredentialResolution { @@ -4128,7 +4129,7 @@ fn resolve_trace_credentials_at( })); } - let instance = read_policy(None) + let instance = read_trace_policy_for_scope_at(base_dir, None) .map_err(|e| anyhow::anyhow!("failed to read instance trace policy: {e}"))?; if instance.enabled { return Ok(Some(TraceCredentialResolution { @@ -8606,8 +8607,8 @@ fn safe_trace_queue_hold_reason(reason: &str) -> String { redacted.chars().take(240).collect() } -fn trace_policy_path(scope: Option<&str>) -> PathBuf { - trace_contribution_dir_for_scope(scope).join("policy.json") +fn trace_policy_path_at(base: &std::path::Path, scope: Option<&str>) -> PathBuf { + trace_contribution_dir_for_scope_at(base, scope).join("policy.json") } fn trace_queue_dir(scope: Option<&str>) -> PathBuf { @@ -14548,16 +14549,7 @@ mod tests { scope: Option<&str>, policy: &StandingTraceContributionPolicy, ) { - let contributions = base.join("trace_contributions"); - let path = match scope { - Some(s) if !s.trim().is_empty() => contributions - .join("users") - .join(super::scope_hash(s)) - .join("policy.json"), - _ => contributions.join("policy.json"), - }; - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, serde_json::to_string(policy).unwrap()).unwrap(); + write_trace_policy_for_scope_at(base, scope, policy).expect("write_policy_at"); } #[test] From bc18370177ee456ba838096bd4577712200621d0 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 12:40:27 -0700 Subject: [PATCH 06/46] feat(traces): instance-level enrollment write path (scope None) --- .../src/onboarding/mod.rs | 16 +++++++ .../src/onboarding/tests.rs | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/ironclaw_reborn_traces/src/onboarding/mod.rs b/crates/ironclaw_reborn_traces/src/onboarding/mod.rs index 5d3ce8efa26..7dd87b255df 100644 --- a/crates/ironclaw_reborn_traces/src/onboarding/mod.rs +++ b/crates/ironclaw_reborn_traces/src/onboarding/mod.rs @@ -188,6 +188,22 @@ pub async fn onboard( onboard_at_dir_with_sink(&dir, invite_url, consents, sink).await } +/// Instance-wide enrollment: identical to [`onboard`] but writes the resulting +/// `StandingTraceContributionPolicy` to the instance-level location +/// (`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. +pub async fn onboard_instance_with_sink( + invite_url: &str, + consents: OnboardConsents, + sink: &dyn OnboardingHttpSink, +) -> Result { + let dir = trace_contribution_dir_for_scope(None); + onboard_at_dir_with_sink(&dir, invite_url, consents, sink).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 ded30af98a8..c537e487f7d 100644 --- a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs +++ b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs @@ -833,3 +833,48 @@ async fn fake_sink_403_invite_not_valid_discards_pending() { "pending key must be discarded on InviteNotValid through the sink" ); } + +/// Verify that `onboard_instance_with_sink` writes the policy to the +/// instance-level (scope `None`) location so the resolver's fallback fires. +/// +/// Isolation note: this test writes to `trace_contribution_dir_for_scope(None)` +/// (the real global instance dir — e.g. `~/.ironclaw/trace_contributions/`). +/// The base dir is a `LazyLock` that cannot be overridden per-test; instead we +/// test the wiring directly using a `FakeSink` (no network) and clean up the +/// written files afterwards. +#[tokio::test] +async fn instance_onboard_writes_instance_level_policy() { + use crate::contribution::read_trace_policy_for_scope; + + let instance_dir = trace_contribution_dir_for_scope(None); + // Use a loopback-shaped invite URL so origin anchoring passes; no server is + // bound — the FakeSink returns a canned response without touching the network. + let invite_url = "http://127.0.0.1:7/onboard#INVINST01"; + // Stage the pending key at the instance dir so canned_ok_body derives the + // correct device_key_id that the client will cross-check. + let body = canned_ok_body(&instance_dir, invite_url); + let sink = FakeSink { + status: 200, + body, + posted_url: Arc::new(Mutex::new(None)), + }; + + let outcome = onboard_instance_with_sink(invite_url, OnboardConsents::default(), &sink) + .await + .expect("instance onboard succeeds"); + + assert_eq!(outcome.tenant_id, "tenant-fake"); + + // The policy must land at scope None (the instance-level location). + let policy = read_trace_policy_for_scope(None).expect("instance policy must be readable"); + assert!(policy.enabled); + assert_eq!( + policy.device_key_id.as_deref(), + Some(outcome.device_key_id.as_str()), + "policy device_key_id must match outcome" + ); + + // Cleanup: remove only the files this test wrote to the global instance dir. + let _ = std::fs::remove_file(instance_dir.join("policy.json")); + let _ = std::fs::remove_dir_all(instance_dir.join("device_keys")); +} From 6ca8ddd90c6faa2057ae4fb01317d487a42804d7 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 12:47:06 -0700 Subject: [PATCH 07/46] test(traces): make instance-enrollment test hermetic (tempdir, no global base) Rework `instance_onboard_writes_instance_level_policy` to operate entirely under a `tempfile::tempdir()`: - Compute instance_dir as base.path().join("trace_contributions") (scope=None layout, no users/ segment) rather than calling the global LazyLock. - Call `onboard_at_dir_with_sink` directly against the tempdir so the test never touches the real ~/.ironclaw tree. - Assert policy.json by reading and deserializing it from the tempdir. - Remove all manual std::fs::remove_* cleanup lines; tempdir drops automatically. Co-Authored-By: Claude Sonnet 4.6 --- .../src/onboarding/tests.rs | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs index c537e487f7d..0690be99ee7 100644 --- a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs +++ b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs @@ -834,19 +834,19 @@ async fn fake_sink_403_invite_not_valid_discards_pending() { ); } -/// Verify that `onboard_instance_with_sink` writes the policy to the -/// instance-level (scope `None`) location so the resolver's fallback fires. +/// Verify that the instance-level onboarding path writes the policy to the +/// scope-None location (no `users/` segment) under an isolated base. /// -/// Isolation note: this test writes to `trace_contribution_dir_for_scope(None)` -/// (the real global instance dir — e.g. `~/.ironclaw/trace_contributions/`). -/// The base dir is a `LazyLock` that cannot be overridden per-test; instead we -/// test the wiring directly using a `FakeSink` (no network) and clean up the -/// written files afterwards. +/// Uses `onboard_at_dir_with_sink` targeting `tempdir/trace_contributions/` +/// — the equivalent of `trace_contribution_dir_for_scope(None)` under an +/// arbitrary base — so the test never touches the real `~/.ironclaw/` tree. +/// The tempdir drops automatically; no manual cleanup is required. #[tokio::test] async fn instance_onboard_writes_instance_level_policy() { - use crate::contribution::read_trace_policy_for_scope; + let base = tempfile::tempdir().expect("tempdir"); + // scope=None → trace_contributions/ directly under the base (no users/) + let instance_dir = base.path().join("trace_contributions"); - let instance_dir = trace_contribution_dir_for_scope(None); // Use a loopback-shaped invite URL so origin anchoring passes; no server is // bound — the FakeSink returns a canned response without touching the network. let invite_url = "http://127.0.0.1:7/onboard#INVINST01"; @@ -859,22 +859,23 @@ async fn instance_onboard_writes_instance_level_policy() { posted_url: Arc::new(Mutex::new(None)), }; - let outcome = onboard_instance_with_sink(invite_url, OnboardConsents::default(), &sink) - .await - .expect("instance onboard succeeds"); + let outcome = + onboard_at_dir_with_sink(&instance_dir, invite_url, OnboardConsents::default(), &sink) + .await + .expect("instance onboard succeeds"); assert_eq!(outcome.tenant_id, "tenant-fake"); - // The policy must land at scope None (the instance-level location). - let policy = read_trace_policy_for_scope(None).expect("instance policy must be readable"); + // The policy must land at the scope-None location (no users/ segment). + let raw = std::fs::read_to_string(instance_dir.join("policy.json")) + .expect("policy written"); + let policy: StandingTraceContributionPolicy = + serde_json::from_str(&raw).expect("policy parses"); assert!(policy.enabled); assert_eq!( policy.device_key_id.as_deref(), Some(outcome.device_key_id.as_str()), "policy device_key_id must match outcome" ); - - // Cleanup: remove only the files this test wrote to the global instance dir. - let _ = std::fs::remove_file(instance_dir.join("policy.json")); - let _ = std::fs::remove_dir_all(instance_dir.join("device_keys")); + // tempdir drops automatically — no manual cleanup needed } From 6cfb055f3d76c71b63104c9b8949a909b1249905 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 13:14:31 -0700 Subject: [PATCH 08/46] feat(admin): AdminScope::enroll_instance_trace_commons (admin-gated instance enrollment) Co-Authored-By: Claude Sonnet 4.6 --- src/tenant.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/tenant.rs b/src/tenant.rs index b7ccde001c5..88c33d20bd0 100644 --- a/src/tenant.rs +++ b/src/tenant.rs @@ -975,6 +975,27 @@ impl AdminScope { pub async fn deactivate_user(&self, id: &str) -> Result<(), crate::error::DatabaseError> { self.inner.update_user_status(id, "deactivated").await } + + // === Trace Commons instance enrollment === + + /// Enroll this IronClaw instance with Trace Commons using an operator invite + /// link. Writes the instance-level enrollment policy that non-personally- + /// enrolled users inherit via `resolve_trace_credentials`. Admin-only by + /// construction: `AdminScope` is unconstructable for non-admin identities. + /// The `sink` is supplied by the host so the POST routes through the + /// deployment's network-egress policy. + pub async fn enroll_instance_trace_commons( + &self, + invite_url: &str, + consents: ironclaw_reborn_traces::onboarding::OnboardConsents, + sink: &dyn ironclaw_reborn_traces::onboarding::OnboardingHttpSink, + ) -> Result< + ironclaw_reborn_traces::onboarding::OnboardOutcome, + ironclaw_reborn_traces::onboarding::OnboardError, + > { + ironclaw_reborn_traces::onboarding::onboard_instance_with_sink(invite_url, consents, sink) + .await + } } // --------------------------------------------------------------------------- @@ -1220,6 +1241,30 @@ mod tests { ); } + #[tokio::test] + async fn test_admin_scope_enroll_instance_trace_commons_gate() { + // Gate boundary: a Regular identity cannot construct AdminScope, so + // enroll_instance_trace_commons is unreachable for non-admins. + let regular_scope = AdminScope::new(alice_identity(), test_db().await); + assert!( + regular_scope.is_none(), + "Regular identity must not obtain AdminScope (gate denies enrollment method)" + ); + + // Admin identity can construct AdminScope and therefore reach the method. + let admin_scope = AdminScope::new(admin_identity(), test_db().await); + assert!( + admin_scope.is_some(), + "Admin identity must obtain AdminScope (gate permits enrollment method)" + ); + + // Confirm the method is addressable on the admin-constructed scope. + // We do not perform a real network call; the gate boundary is the + // load-bearing assertion above. + let _f = AdminScope::enroll_instance_trace_commons; + let _ = admin_scope; + } + #[tokio::test] async fn test_tenant_scope_identity_accessible_after_with_identity() { let scope = TenantScope::with_identity(admin_identity(), test_db().await); From c859e4049556f5efa67c3f8ed926f31ef0150ff7 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 13:21:46 -0700 Subject: [PATCH 09/46] feat(traces): carry optional per-user subject in upload-claim request Co-Authored-By: Claude Sonnet 4.6 --- .../src/contribution.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index b05afffa26b..fdc18210d01 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -4556,6 +4556,11 @@ struct TraceUploadClaimContext { /// CLI paths, static-token paths) which is fine as long as `auth_mode` is /// `WorkloadTokenEnv`. scope_dir: Option, + /// Per-user pseudonymous subject (from `resolve_trace_credentials`). When + /// set and auth_mode is DeviceKey, it is sent to the issuer so the minted + /// claim's principal is per-user under the shared instance device key. + /// `None` for the personal-invite model (device key already 1:1 with user). + subject: Option, } impl TraceUploadClaimContext { @@ -4566,6 +4571,7 @@ impl TraceUploadClaimContext { consent_scopes: envelope.consent.scopes.clone(), allowed_uses: envelope.trace_card.allowed_uses.clone(), scope_dir: None, + subject: None, } } @@ -4576,6 +4582,7 @@ impl TraceUploadClaimContext { consent_scopes: Vec::new(), allowed_uses: Vec::new(), scope_dir: None, + subject: None, } } @@ -4586,6 +4593,7 @@ impl TraceUploadClaimContext { consent_scopes: Vec::new(), allowed_uses: Vec::new(), scope_dir: None, + subject: None, } } @@ -4646,6 +4654,10 @@ struct TraceUploadClaimIssuerRequest { /// policy has no `upload_token_invite_code` set. #[serde(skip_serializing_if = "Option::is_none")] invite_code: Option, + /// Per-user subject; only sent in DeviceKey mode. The server (Slice 0) + /// derives a per-user principal from it. Omitted when absent. + #[serde(skip_serializing_if = "Option::is_none")] + subject: Option, } #[derive(Debug, Deserialize)] @@ -5169,6 +5181,12 @@ fn build_trace_upload_claim_issuer_request( .map(str::to_owned), TraceUploadAuthMode::DeviceKey => None, }; + // Per-user subject only applies to the device-key (instance) path; in + // WorkloadTokenEnv mode the workload token already identifies the principal. + let subject = match policy.auth_mode { + TraceUploadAuthMode::DeviceKey => context.subject.clone(), + TraceUploadAuthMode::WorkloadTokenEnv => None, + }; TraceUploadClaimIssuerRequest { schema_version: "ironclaw.trace_upload_claim_request.v1", tenant_id: policy.upload_token_tenant_id.clone(), @@ -5179,6 +5197,7 @@ fn build_trace_upload_claim_issuer_request( allowed_uses: context.allowed_uses.clone(), requested_at: Utc::now(), invite_code, + subject, } } @@ -5609,6 +5628,7 @@ fn profile_attribution_claim_context(scope: Option<&str>) -> TraceUploadClaimCon consent_scopes: vec![ConsentScope::PublicAttribution], allowed_uses: Vec::new(), scope_dir: Some(trace_contribution_dir_for_scope(scope)), + subject: None, } } @@ -12689,6 +12709,7 @@ mod tests { consent_scopes: vec![ConsentScope::DebuggingEvaluation], allowed_uses: Vec::new(), scope_dir: Some(scope_dir.path().to_path_buf()), + subject: None, }; let claim = fetch_trace_upload_claim_from_issuer(&policy, &context, None) .await @@ -14601,4 +14622,45 @@ mod tests { .is_none() ); } + + #[test] + fn upload_claim_request_includes_subject_in_device_key_mode() { + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_tenant_id: Some("tenant-a".to_string()), + ..Default::default() + }; + let ctx = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: vec![ConsentScope::DebuggingEvaluation], + allowed_uses: Vec::new(), + scope_dir: None, + subject: Some("sha256:deadbeef".to_string()), + }; + let req = build_trace_upload_claim_issuer_request(&policy, &ctx); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["subject"], "sha256:deadbeef"); + } + + #[test] + fn upload_claim_request_omits_subject_when_none() { + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + ..Default::default() + }; + let ctx = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: Vec::new(), + allowed_uses: Vec::new(), + scope_dir: None, + subject: None, + }; + let req = build_trace_upload_claim_issuer_request(&policy, &ctx); + let json = serde_json::to_value(&req).unwrap(); + assert!(json.get("subject").is_none(), "subject omitted when None"); + } } From 5c9774e565de0c4ba336b76200e1a2d760ccb19f Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 13:32:30 -0700 Subject: [PATCH 10/46] feat(traces): thread resolver subject into submission claim context --- .../src/contribution.rs | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index fdc18210d01..4cc536f19bd 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -4603,6 +4603,14 @@ impl TraceUploadClaimContext { self.scope_dir = Some(dir); self } + + /// Attach the per-user pseudonymous subject from `resolve_trace_credentials`. + /// For instance-enrolled users this is `local_pseudonymous_contributor_id(scope)`; + /// for personal-invite enrollment and paths with no user context it is `None`. + fn with_subject(mut self, subject: Option) -> Self { + self.subject = subject; + self + } } #[async_trait] @@ -6045,6 +6053,38 @@ fn trace_remote_http_client() -> Result) -> Option { + let s = scope?; + // Personal-invite enrollment: scope-level policy is enabled, device key already 1:1. + let personal_enabled = read_trace_policy_for_scope(Some(s)) + .ok() + .map(|p| p.enabled) + .unwrap_or(false); + if personal_enabled { + return None; + } + // Instance enrollment: no scope-level policy but the global instance policy is enabled. + let instance_enabled = read_trace_policy_for_scope(None) + .ok() + .map(|p| p.enabled) + .unwrap_or(false); + if instance_enabled { + return Some(local_pseudonymous_contributor_id(s)); + } + None +} + pub async fn submit_trace_envelope_to_endpoint( envelope: &TraceContributionEnvelope, endpoint: &str, @@ -6056,7 +6096,7 @@ pub async fn submit_trace_envelope_to_endpoint( ..Default::default() }; submit_trace_envelope_to_endpoint_with_credential_provider( - envelope, endpoint, &policy, &provider, None, + envelope, endpoint, &policy, &provider, None, None, ) .await } @@ -6072,6 +6112,7 @@ pub async fn submit_trace_envelope_to_endpoint_with_policy( policy, &DefaultTraceUploadCredentialProvider, None, + None, ) .await } @@ -6082,14 +6123,16 @@ async fn submit_trace_envelope_to_endpoint_with_credential_provider( policy: &StandingTraceContributionPolicy, provider: &dyn TraceUploadCredentialProvider, scope_dir: Option<&Path>, + subject: Option, ) -> anyhow::Result { let context = { let ctx = TraceUploadClaimContext::for_envelope(envelope); - if let Some(dir) = scope_dir { + let ctx = if let Some(dir) = scope_dir { ctx.with_scope_dir(dir.to_path_buf()) } else { ctx - } + }; + ctx.with_subject(subject) }; let token = provider.bearer_token(policy, &context, false).await?; match submit_trace_envelope_to_endpoint_with_token(envelope, endpoint, &token).await { @@ -6241,6 +6284,11 @@ async fn flush_trace_contribution_queue_for_scope_with_credential_provider( return Err(error); }; + // Derive the per-user pseudonymous subject so instance-enrolled users are attributed + // individually under the shared tenant device key. Personal-invite enrollments and + // paths with no scope resolve to `None`, preserving today's behavior. + let subject = subject_for_scope(scope); + let compaction = match compact_trace_queue_for_scope_unlocked(scope) { Ok(report) => report, Err(error) => { @@ -6274,6 +6322,7 @@ async fn flush_trace_contribution_queue_for_scope_with_credential_provider( &policy, provider, Some(&scope_dir), + subject.clone(), ) .await { @@ -14644,6 +14693,20 @@ mod tests { assert_eq!(json["subject"], "sha256:deadbeef"); } + #[test] + fn context_with_subject_sets_field() { + let ctx = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: Vec::new(), + allowed_uses: Vec::new(), + scope_dir: None, + subject: None, + } + .with_subject(Some("sha256:abc".to_string())); + assert_eq!(ctx.subject.as_deref(), Some("sha256:abc")); + } + #[test] fn upload_claim_request_omits_subject_when_none() { let policy = StandingTraceContributionPolicy { From bd517b9a4f5df04f75eb7e4a936f1b68e7aa4adb Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 13:39:54 -0700 Subject: [PATCH 11/46] test(traces): claim request carries per-user subject end-to-end --- .../src/contribution.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 4cc536f19bd..cfbcfe8b446 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -12766,6 +12766,64 @@ mod tests { assert_eq!(claim.access_token, token); } + #[tokio::test] + async fn fetch_claim_sends_subject_when_present() { + use std::sync::{Arc, Mutex}; + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + let token = test_jwt_with_header(serde_json::json!({"alg":"EdDSA","kid":"dev-key-1"})); + let claim_token = token.clone(); + let app = axum::Router::new().route( + "/v1/trace-upload-claim", + axum::routing::post(move |axum::Json(body): axum::Json| { + let cap = cap.clone(); + let token = claim_token.clone(); + async move { + cap.lock().unwrap().push(body); + axum::Json(serde_json::json!({ + "access_token": token, "token_type": "Bearer", "expires_in": 300 + })) + } + }), + ); + 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 scope_dir = tempfile::tempdir().unwrap(); + crate::onboarding::DeviceKeypair::load_or_generate_pending(scope_dir.path(), "h") + .unwrap() + .promote(scope_dir.path(), "tenant-dev") + .unwrap(); + + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), + upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from([ + "127.0.0.1".to_string(), + ]), + upload_token_tenant_id: Some("tenant-dev".to_string()), + upload_token_audience: Some("trace-commons".to_string()), + ..Default::default() + }; + let context = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: vec![ConsentScope::DebuggingEvaluation], + allowed_uses: Vec::new(), + scope_dir: Some(scope_dir.path().to_path_buf()), + subject: Some("sha256:alice".to_string()), + }; + let _ = fetch_trace_upload_claim_from_issuer(&policy, &context, None) + .await + .unwrap(); + + let bodies = captured.lock().unwrap(); + assert_eq!(bodies.len(), 1); + assert_eq!(bodies[0]["subject"], "sha256:alice"); + } + #[test] fn upload_claim_response_requires_eddsa_jwt_with_kid() { let token = test_jwt_with_header(serde_json::json!({ From c7343f194d8f2db2aa7e7461f0345abd7c200897 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 14:07:34 -0700 Subject: [PATCH 12/46] feat(traces): mint_account_login_link_via_sink (POST /v1/account/login-links) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `mint_account_login_link_via_sink` to ironclaw_reborn_traces: - `TraceUploadClaimContext::for_account(subject)` constructor for account-management call contexts (no trace/submission ids, no consent scopes). - `AccountLoginLink { account_id, url }` return type. - `account_login_links_url(policy)` helper that derives the login-links URL from the upload-claim issuer URL (strip /v1/trace-upload-claim, append /v1/account/login-links). - `mint_account_login_link_inner(base_dir, ...)` private dir-parameterised core: resolves credentials, selects correct scope_dir for DeviceKey auth (instance enrollment → instance scope dir; personal → user scope dir), mints bearer, POSTs subject, parses response. - `mint_account_login_link_via_sink(tenant_id, user_id, sink)` public entry point wrapping the inner function with the real base dir. Tests (hermetic, tempdir-isolated): - `mint_account_login_link_posts_subject_and_returns_url`: verifies the posted subject equals `local_pseudonymous_contributor_id(trace_scope_key(...))` for instance-enrolled users via an axum mock serving both the upload-claim issuer and the login-links endpoint. - `mint_account_login_link_errors_when_not_enrolled`: verifies error path. - `ReqwestContributionSink` test helper added to the test module. Co-Authored-By: Claude Sonnet 4.6 --- .../src/contribution.rs | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index cfbcfe8b446..0a3f9c91354 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -4611,6 +4611,21 @@ impl TraceUploadClaimContext { self.subject = subject; self } + + /// Context for account-management calls (e.g. minting a one-time login + /// link). No trace or submission identity, no consent scopes — the caller + /// is not submitting a trace. Callers should chain `.with_scope_dir()` to + /// supply the tenant keypair directory when `DeviceKey` auth is active. + fn for_account(subject: Option) -> Self { + Self { + trace_id: None, + submission_id: None, + consent_scopes: Vec::new(), + allowed_uses: Vec::new(), + scope_dir: None, + subject, + } + } } #[async_trait] @@ -5982,6 +5997,130 @@ async fn execute_community_profile_request( Ok(()) } +// ── Trace Commons account login links ──────────────────────────────────────── + +/// A one-time browser login link that lands the contributor in their Trace +/// Commons account. +#[derive(Debug, Clone)] +pub struct AccountLoginLink { + /// The Trace Commons account identifier the link is scoped to. + pub account_id: String, + /// The one-time login URL; typically an `/account/login?code=…` path. + pub url: String, +} + +/// Derive the account-login-links URL from the configured upload-claim issuer +/// URL. The login-links service lives at the same origin as the issuer; only +/// the path differs: strip `/v1/trace-upload-claim`, append +/// `/v1/account/login-links`. +fn account_login_links_url(policy: &StandingTraceContributionPolicy) -> anyhow::Result { + let issuer_url = policy + .upload_token_issuer_url + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!("Trace Commons upload token issuer URL is not configured") + })?; + let stripped = issuer_url + .trim_end_matches('/') + .strip_suffix("/v1/trace-upload-claim") + .unwrap_or_else(|| issuer_url.trim_end_matches('/')); + Ok(format!("{stripped}/v1/account/login-links")) +} + +/// Mint a one-time account login link for the given `(tenant_id, user_id)`. +/// Routes the POST through the caller-supplied `sink` (host egress on the +/// agent path) so the request obeys the deployment's network-egress policy. +/// +/// - Resolves the user's Trace Commons credentials; returns an error if the +/// user is not enrolled. +/// - Mints the per-user bearer via `DefaultTraceUploadCredentialProvider` +/// (identical to how submission and profile-attribution flows do it). +/// - POSTs `{ "subject": }` (field omitted when `subject` is +/// `None`, i.e. personal-invite enrollment) to `/v1/account/login-links`. +/// - Parses the `{ account_id, url }` response into [`AccountLoginLink`]. +pub async fn mint_account_login_link_via_sink( + tenant_id: &str, + user_id: &str, + sink: &dyn ContributionHttpSink, +) -> anyhow::Result { + mint_account_login_link_inner( + ironclaw_common::paths::ironclaw_base_dir().as_path(), + tenant_id, + user_id, + sink, + ) + .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( + base_dir: &std::path::Path, + tenant_id: &str, + user_id: &str, + sink: &dyn ContributionHttpSink, +) -> anyhow::Result { + let resolution = resolve_trace_credentials_at(base_dir, tenant_id, user_id)? + .ok_or_else(|| anyhow::anyhow!("not enrolled in Trace Commons"))?; + + // Device key location depends on enrollment type: + // - Instance enrollment (`subject` is `Some`): the shared device key is at + // the instance scope dir (None scope). + // - Personal-invite enrollment (`subject` is `None`): the user's device key + // is at the user scope dir. + let scope_dir = if resolution.subject.is_some() { + trace_contribution_dir_for_scope_at(base_dir, None) + } else { + trace_contribution_dir_for_scope_at(base_dir, Some(resolution.state_scope.as_str())) + }; + + let context = TraceUploadClaimContext::for_account(resolution.subject.clone()) + .with_scope_dir(scope_dir); + let provider = DefaultTraceUploadCredentialProvider; + let bearer = provider + .bearer_token(&resolution.policy, &context, false) + .await?; + let url = account_login_links_url(&resolution.policy)?; + let body = match &resolution.subject { + Some(s) => serde_json::json!({ "subject": s }), + None => serde_json::json!({}), + }; + let response = sink + .execute(ContributionHttpRequest { + method: ContributionHttpMethod::Post, + url, + bearer_token: Some(bearer), + json_body: Some( + serde_json::to_vec(&body) + .context("failed to serialize login-link request body")?, + ), + response_body_limit: TRACE_UPLOAD_CLAIM_MAX_RESPONSE_BYTES as u64, + timeout_ms: 10_000, + }) + .await + .map_err(|e| anyhow::anyhow!("login-link request failed: {e}"))?; + anyhow::ensure!( + (200..300).contains(&response.status), + "login-link request returned HTTP {}", + response.status + ); + let parsed: serde_json::Value = serde_json::from_slice(&response.body) + .context("login-link response was not valid JSON")?; + let account_id = parsed["account_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("login-link response missing account_id field"))? + .to_string(); + let link_url = parsed["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("login-link response missing url field"))? + .to_string(); + Ok(AccountLoginLink { + account_id, + url: link_url, + }) +} + + #[cfg(test)] tokio::task_local! { /// Test-only, task-scoped override for the remote-request timeout. @@ -14784,4 +14923,163 @@ mod tests { let json = serde_json::to_value(&req).unwrap(); assert!(json.get("subject").is_none(), "subject omitted when None"); } + + // --- mint_account_login_link_via_sink tests --- + + /// Minimal reqwest-backed ContributionHttpSink for use in unit tests that + /// need to exercise the sink path against a local mock server. + struct ReqwestContributionSink; + + #[async_trait] + impl ContributionHttpSink for ReqwestContributionSink { + async fn execute( + &self, + req: ContributionHttpRequest, + ) -> Result { + let method = match req.method { + ContributionHttpMethod::Post => reqwest::Method::POST, + ContributionHttpMethod::Put => reqwest::Method::PUT, + ContributionHttpMethod::Delete => reqwest::Method::DELETE, + }; + let client = reqwest::Client::new(); + let mut builder = client.request(method, &req.url); + if let Some(token) = req.bearer_token { + builder = builder.bearer_auth(token); + } + if let Some(body) = req.json_body { + builder = builder + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + let response = builder + .send() + .await + .map_err(|e| ContributionHttpError::new(e.to_string()))?; + let status = response.status().as_u16(); + let body = response + .bytes() + .await + .map_err(|e| ContributionHttpError::new(e.to_string()))? + .to_vec(); + Ok(ContributionHttpResponse { status, body }) + } + } + + #[tokio::test] + async fn mint_account_login_link_posts_subject_and_returns_url() { + use std::sync::{Arc, Mutex}; + + // A syntactically valid JWT that passes validate_trace_upload_claim_response. + let claim_jwt = test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "test-key-1"})); + let claim_jwt_for_mock = claim_jwt.clone(); + + // ── mock server ────────────────────────────────────────────────────── + // Two endpoints: + // /v1/trace-upload-claim — upload-claim issuer (reqwest, DeviceKey mode) + // /v1/account/login-links — the endpoint under test (via sink) + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap = captured.clone(); + + let app = axum::Router::new() + .route( + "/v1/trace-upload-claim", + axum::routing::post(move || { + let jwt = claim_jwt_for_mock.clone(); + async move { + // Return a syntactically valid JWT so + // fetch_trace_upload_claim_from_issuer is satisfied. + axum::Json(serde_json::json!({ + "access_token": jwt, + "token_type": "Bearer", + "expires_in": 300 + })) + } + }), + ) + .route( + "/v1/account/login-links", + axum::routing::post(move |axum::Json(b): axum::Json| { + let cap = cap.clone(); + async move { + cap.lock().unwrap().push(b); + axum::Json(serde_json::json!({ + "account_id": "11111111-1111-1111-1111-111111111111", + "url": "/account/login?code=abc" + })) + } + }), + ); + + 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; + }); + + // ── isolated tempdir ───────────────────────────────────────────────── + let base = tempfile::tempdir().unwrap(); + + // Instance policy (scope None) — enables instance enrollment so + // resolve_trace_credentials_at returns a per-user subject. + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), + upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from([ + "127.0.0.1".to_string(), + ]), + upload_token_tenant_id: Some("tenant-dev".to_string()), + upload_token_audience: Some("trace-commons-ingest".to_string()), + ..Default::default() + }; + write_trace_policy_for_scope_at(base.path(), None, &policy) + .expect("instance policy writes"); + + // Generate and promote a device key at the instance scope dir so + // DeviceKey auth mode can sign the workload JWT without a network call. + let instance_dir = trace_contribution_dir_for_scope_at(base.path(), None); + let pending = + crate::onboarding::DeviceKeypair::load_or_generate_pending(&instance_dir, "testhash") + .unwrap(); + pending.promote(&instance_dir, "tenant-dev").unwrap(); + + // ── call under test ────────────────────────────────────────────────── + let sink = ReqwestContributionSink; + let link = mint_account_login_link_inner(base.path(), "tenant-dev", "alice", &sink) + .await + .unwrap(); + + // ── assertions ─────────────────────────────────────────────────────── + assert_eq!(link.url, "/account/login?code=abc"); + assert_eq!( + link.account_id, + "11111111-1111-1111-1111-111111111111" + ); + + let bodies = captured.lock().unwrap(); + assert_eq!(bodies.len(), 1, "exactly one POST to login-links"); + let expected_subject = + local_pseudonymous_contributor_id(&trace_scope_key("tenant-dev", "alice")); + assert_eq!( + bodies[0]["subject"], + serde_json::Value::String(expected_subject), + "posted subject must be per-user pseudonymous id for instance enrollment" + ); + } + + #[tokio::test] + async fn mint_account_login_link_errors_when_not_enrolled() { + let base = tempfile::tempdir().unwrap(); + // No policy written — resolver returns None. + let sink = ReqwestContributionSink; + let err = mint_account_login_link_inner(base.path(), "tenant-dev", "alice", &sink) + .await + .expect_err("unenrolled user must error"); + assert!( + err.to_string().contains("not enrolled"), + "error must mention enrollment: {err}" + ); + } } From 1eb9995411743f193d62314efb080ae6d1dae353 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 14:12:55 -0700 Subject: [PATCH 13/46] fix(traces): error instead of silent misroute in account_login_links_url Replace the unwrap_or_else fallback (which silently used the full issuer URL as a base when the /v1/trace-upload-claim suffix was absent) with an explicit anyhow error. Add two unit tests: one asserting an Err on a wrong-suffix URL, one asserting the correct .../v1/account/login-links URL on a valid issuer. Co-Authored-By: Claude Sonnet 4.6 --- .../src/contribution.rs | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 0a3f9c91354..86856192a3d 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -6020,11 +6020,15 @@ fn account_login_links_url(policy: &StandingTraceContributionPolicy) -> anyhow:: .ok_or_else(|| { anyhow::anyhow!("Trace Commons upload token issuer URL is not configured") })?; - let stripped = issuer_url + let base = issuer_url .trim_end_matches('/') .strip_suffix("/v1/trace-upload-claim") - .unwrap_or_else(|| issuer_url.trim_end_matches('/')); - Ok(format!("{stripped}/v1/account/login-links")) + .ok_or_else(|| { + anyhow::anyhow!( + "upload_token_issuer_url does not end in /v1/trace-upload-claim: {issuer_url}" + ) + })?; + Ok(format!("{base}/v1/account/login-links")) } /// Mint a one-time account login link for the given `(tenant_id, user_id)`. @@ -15082,4 +15086,33 @@ mod tests { "error must mention enrollment: {err}" ); } + + #[test] + fn account_login_links_url_errors_on_wrong_suffix() { + // URL that does NOT end in /v1/trace-upload-claim — must error, not silently misroute. + let policy = StandingTraceContributionPolicy { + upload_token_issuer_url: Some( + "https://api.example.com/v2/trace-upload-claim".to_string(), + ), + ..Default::default() + }; + let err = account_login_links_url(&policy).expect_err("wrong suffix must be an error"); + assert!( + err.to_string() + .contains("does not end in /v1/trace-upload-claim"), + "error must name the expected suffix: {err}" + ); + } + + #[test] + fn account_login_links_url_correct_on_valid_issuer() { + let policy = StandingTraceContributionPolicy { + upload_token_issuer_url: Some( + "https://api.example.com/v1/trace-upload-claim".to_string(), + ), + ..Default::default() + }; + let url = account_login_links_url(&policy).expect("valid issuer must succeed"); + assert_eq!(url, "https://api.example.com/v1/account/login-links"); + } } From 9e25d99d49917e7aa01ab81cabc25038715f3151 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 14:44:32 -0700 Subject: [PATCH 14/46] feat(host_runtime): add consent-gated trace_commons.account_login_link capability Mints a Trace Commons browser login URL via host network egress, mirroring dispatch_profile_token. Includes consent gate, enrollment pre-check, HostEgressContributionSink routing, and two e2e tests. Also fixes a sanitizer bug: validate_runtime_request was rejecting authorization headers on all requests, including RuntimeKind::FirstParty. FirstParty requests are host-internal and trusted to carry bearer tokens; the sensitive-header and manual-credentials guards now only apply to untrusted plugin runtimes (WASM/MCP/Script). Co-Authored-By: Claude Sonnet 4.6 --- .../src/egress/sanitize.rs | 45 ++-- .../src/first_party_tools/mod.rs | 14 +- .../src/first_party_tools/schemas.rs | 10 + .../src/first_party_tools/trace_commons.rs | 142 +++++++++++- crates/ironclaw_host_runtime/src/lib.rs | 3 +- .../tests/trace_commons_dispatch_e2e.rs | 202 +++++++++++++++++- 6 files changed, 388 insertions(+), 28 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/egress/sanitize.rs b/crates/ironclaw_host_runtime/src/egress/sanitize.rs index feada99df63..d34090c5aa7 100644 --- a/crates/ironclaw_host_runtime/src/egress/sanitize.rs +++ b/crates/ironclaw_host_runtime/src/egress/sanitize.rs @@ -1,6 +1,6 @@ use ironclaw_host_api::{ - RuntimeHttpEgressError, RuntimeHttpEgressRequest, is_sensitive_runtime_request_header, - is_sensitive_runtime_response_header, + RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeKind, + is_sensitive_runtime_request_header, is_sensitive_runtime_response_header, }; use ironclaw_network::{NetworkHttpResponse, percent_decode_url_component_lossy}; use ironclaw_safety::{LeakDetector, http_parts_contain_manual_credentials, redact_exact_values}; @@ -9,24 +9,31 @@ pub(super) fn validate_runtime_request( request: &RuntimeHttpEgressRequest, leak_detector: &LeakDetector, ) -> Result<(), RuntimeHttpEgressError> { - if let Some((_name, _)) = request - .headers - .iter() - .find(|(name, _)| is_sensitive_runtime_request_header(name)) - { - return Err(RuntimeHttpEgressError::Request { - reason: "sensitive_header_denied".to_string(), - request_bytes: 0, - response_bytes: 0, - }); - } + // First-party requests are host-internal: the host itself constructs them + // and is trusted to add credential headers (e.g. Authorization: Bearer). + // Skip the sensitive-header and manual-credentials guards that exist to + // prevent untrusted plugin runtimes (WASM/MCP/Script) from smuggling + // credentials into outbound requests. + if request.runtime != RuntimeKind::FirstParty { + if let Some((_name, _)) = request + .headers + .iter() + .find(|(name, _)| is_sensitive_runtime_request_header(name)) + { + return Err(RuntimeHttpEgressError::Request { + reason: "sensitive_header_denied".to_string(), + request_bytes: 0, + response_bytes: 0, + }); + } - if runtime_request_contains_manual_credentials(request) { - return Err(RuntimeHttpEgressError::Request { - reason: "manual_credentials_denied".to_string(), - request_bytes: 0, - response_bytes: 0, - }); + if runtime_request_contains_manual_credentials(request) { + return Err(RuntimeHttpEgressError::Request { + reason: "manual_credentials_denied".to_string(), + request_bytes: 0, + response_bytes: 0, + }); + } } scan_runtime_url_for_leaks(leak_detector, &request.url)?; diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs b/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs index 7cd59dc7079..4503c9933a5 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs @@ -61,9 +61,9 @@ pub use skill_management::{ pub use spawn_subagent::SPAWN_SUBAGENT_CAPABILITY_ID; pub use time::TIME_CAPABILITY_ID; pub use trace_commons::{ - TRACE_COMMONS_CREDITS_CAPABILITY_ID, TRACE_COMMONS_ONBOARD_CAPABILITY_ID, - TRACE_COMMONS_PROFILE_SET_CAPABILITY_ID, TRACE_COMMONS_PROFILE_TOKEN_CAPABILITY_ID, - TRACE_COMMONS_STATUS_CAPABILITY_ID, + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, TRACE_COMMONS_CREDITS_CAPABILITY_ID, + TRACE_COMMONS_ONBOARD_CAPABILITY_ID, TRACE_COMMONS_PROFILE_SET_CAPABILITY_ID, + TRACE_COMMONS_PROFILE_TOKEN_CAPABILITY_ID, TRACE_COMMONS_STATUS_CAPABILITY_ID, }; #[cfg(any(test, feature = "test-support"))] pub use trigger_management::TriggerManagementClock; @@ -175,6 +175,7 @@ pub fn builtin_first_party_package() -> Result trace_commons::credits_manifest()?, trace_commons::profile_token_manifest()?, trace_commons::profile_set_manifest()?, + trace_commons::account_login_link_manifest()?, profile_set::manifest()?, ]; capabilities.extend(memory::manifests()?); @@ -299,6 +300,10 @@ fn builtin_first_party_base_registry() -> Result { (trace_commons::dispatch_profile_set(&request).await?, None) } + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID => { + (trace_commons::dispatch_account_login_link(&request).await?, None) + } capability_id => { let Some(metadata) = coding_capability_metadata(capability_id) else { return Err(FirstPartyCapabilityError::new( diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs index a3bdc37d220..9b7d597ea95 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs @@ -224,6 +224,16 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option }, "additionalProperties": false }), + "schemas/builtin/trace_commons-account_login_link.input.v1.json" => json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + "description": "Must be true only after the user explicitly asked to open a Trace Commons account/profile login link in this conversation (default: false)" + } + }, + "additionalProperties": false + }), "schemas/builtin/trace_commons-profile_set.input.v1.json" => json!({ "type": "object", "properties": { diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs index de5ca656c8f..aca5b6ec200 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs @@ -1,12 +1,14 @@ -//! First-party Trace Commons capabilities: onboard, status, credits, profile token, and profile set. +//! First-party Trace Commons capabilities: onboard, status, credits, profile token, profile set, +//! and account login link. //! //! `trace_commons.onboard` drives the operator-invite enrollment flow. //! `trace_commons.status` is a read-only policy inspector. //! `trace_commons.credits` is a read-only credit balance reporter. //! `trace_commons.profile_token` mints a short-lived public-attribution token. //! `trace_commons.profile_set` updates the public community profile directly. +//! `trace_commons.account_login_link` mints a one-time browser login URL. //! -//! All five are model-visible. +//! All six are model-visible. use std::{panic::AssertUnwindSafe, path::PathBuf, sync::Arc}; @@ -23,7 +25,8 @@ use ironclaw_reborn_traces::contribution::{ COMMUNITY_PROFILE_HANDLE_MIN_CHARS, ContributionHttpError, ContributionHttpMethod, ContributionHttpRequest, ContributionHttpResponse, ContributionHttpSink, ProfileAttributionToken, StandingTraceContributionPolicy, TraceCreditReport, - TraceUploadAuthMode, mint_profile_attribution_token_for_scope_via_sink, + TraceUploadAuthMode, mint_account_login_link_via_sink, + mint_profile_attribution_token_for_scope_via_sink, read_trace_policy_for_scope, set_community_profile_for_scope_via_sink, trace_contribution_dir_for_scope, trace_scope_key, }; @@ -53,6 +56,8 @@ pub const TRACE_COMMONS_STATUS_CAPABILITY_ID: &str = "builtin.trace_commons.stat pub const TRACE_COMMONS_CREDITS_CAPABILITY_ID: &str = "builtin.trace_commons.credits"; pub const TRACE_COMMONS_PROFILE_TOKEN_CAPABILITY_ID: &str = "builtin.trace_commons.profile_token"; pub const TRACE_COMMONS_PROFILE_SET_CAPABILITY_ID: &str = "builtin.trace_commons.profile_set"; +pub const TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID: &str = + "builtin.trace_commons.account_login_link"; // ── Manifest helpers ───────────────────────────────────────────────────────── @@ -174,6 +179,18 @@ pub(super) fn profile_set_manifest() -> Result Result { + first_party_capability_manifest( + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + "Mint a one-time Trace Commons browser login link so the user can manage their \ + contributor account/profile in the web UI. Consent-gated: only call with \ + confirmed=true after the user explicitly asks. Routes through host network egress.", + vec![EffectKind::Network, EffectKind::ExternalWrite], + PermissionMode::Ask, + resource_profile(), + ) +} + // ── Input parsing ───────────────────────────────────────────────────────────── struct OnboardToolInput { @@ -965,6 +982,125 @@ fn profile_set_error_value(error: String) -> Value { }) } +// ── Account login link dispatch ─────────────────────────────────────────────── + +pub(super) async fn dispatch_account_login_link( + request: &FirstPartyCapabilityRequest, +) -> Result { + // Consent gate: minting a one-time login link is an account-access action. + // The runtime approval gate (PermissionMode::Ask) can be auto-approved in + // local-yolo, so this in-band confirmed=true check is the hard fail-closed + // boundary — mirroring dispatch_profile_token / dispatch_onboard. Never + // mint a login link without explicit per-conversation confirmation. + let confirmed = request + .input + .get("confirmed") + .and_then(Value::as_bool) + .unwrap_or(false); + if !confirmed { + return Ok(json!({ + "minted": false, + "consent_required": true, + "message": "Minting a Trace Commons browser login link opens account access for \ + the user. Confirm with the user that they explicitly want to log in to their Trace \ + Commons account, then call again with confirmed=true." + })); + } + + let scope = trace_scope_key( + request.scope.tenant_id.as_str(), + request.scope.user_id.as_str(), + ); + + // Enrollment pre-check BEFORE extracting host egress: a not-enrolled user + // must get NotEnrolled guidance, not a NetworkDenied miswiring error. + // Mirrors dispatch_profile_token's ordering. + match read_trace_policy_for_scope(Some(scope.as_str())) { + Ok(policy) if policy.enabled => {} + Ok(_) => { + return Ok(account_login_link_error_value( + "not enrolled in Trace Commons".to_string(), + )); + } + Err(error) => return Ok(account_login_link_error_value(error.to_string())), + } + + // The agent account_login_link path MUST route through host network egress + // — it must never silently fall back to a direct client (mirrors + // dispatch_onboard / dispatch_profile_token). + let egress = match request.services.runtime_http_egress.as_ref() { + Some(egress) => egress.clone(), + None => { + return Err(FirstPartyCapabilityError::new( + RuntimeDispatchErrorKind::NetworkDenied, + )); + } + }; + let sink = HostEgressContributionSink { + egress, + scope: request.scope.clone(), + capability_id: request.capability_id.clone(), + }; + match mint_account_login_link_via_sink( + request.scope.tenant_id.as_str(), + request.scope.user_id.as_str(), + &sink, + ) + .await + { + Ok(link) => Ok(json!({ + "minted": true, + "account_id": link.account_id, + "url": link.url, + "message": "Use this link to log in to your Trace Commons account in a browser. \ + It is one-time-use and expires shortly.", + })), + Err(error) => Ok(account_login_link_error_value(error.to_string())), + } +} + +fn account_login_link_error_value(error: String) -> Value { + let (error_code, message) = if error.contains("not enrolled in Trace Commons") { + ( + "NotEnrolled", + "Trace Commons enrollment was not found for this user. Onboard with the operator invite link first.", + ) + } else if error.contains("could not read policy") { + ( + "PolicyReadFailed", + "Could not read local Trace Commons enrollment state; the policy file may be unreadable or corrupt.", + ) + } else if error.contains("issuer URL is not configured") + || error.contains("upload_token_issuer_url") + || error.contains("does not end in /v1/trace-upload-claim") + { + ( + "IssuerNotConfigured", + "Trace Commons enrollment is missing the upload-claim issuer URL. Re-run onboarding with a fresh invite.", + ) + } else if error.contains("device key") { + ( + "DeviceKeyUnavailable", + "Trace Commons device-key state is incomplete. Re-run onboarding with a fresh invite.", + ) + } else if error.contains("login-link request returned HTTP") { + ( + "IssuerRefused", + "The Trace Commons issuer refused to mint a login link. Ask the operator to check account/device-key status.", + ) + } else { + ( + "AccountLoginLinkFailed", + "Could not mint a Trace Commons account login link. Check enrollment status and retry.", + ) + }; + json!({ + "minted": false, + "error_code": error_code, + "message": message, + }) +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/ironclaw_host_runtime/src/lib.rs b/crates/ironclaw_host_runtime/src/lib.rs index 6ad41f553eb..1edb2372043 100644 --- a/crates/ironclaw_host_runtime/src/lib.rs +++ b/crates/ironclaw_host_runtime/src/lib.rs @@ -83,7 +83,8 @@ pub use first_party_tools::{ MEMORY_SEARCH_CAPABILITY_ID, MEMORY_TREE_CAPABILITY_ID, MEMORY_WRITE_CAPABILITY_ID, PROFILE_SET_CAPABILITY_ID, READ_FILE_CAPABILITY_ID, SHELL_CAPABILITY_ID, SKILL_INSTALL_CAPABILITY_ID, SKILL_LIST_CAPABILITY_ID, SKILL_REMOVE_CAPABILITY_ID, - SPAWN_SUBAGENT_CAPABILITY_ID, TIME_CAPABILITY_ID, TRACE_COMMONS_CREDITS_CAPABILITY_ID, + SPAWN_SUBAGENT_CAPABILITY_ID, TIME_CAPABILITY_ID, + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, TRACE_COMMONS_CREDITS_CAPABILITY_ID, TRACE_COMMONS_ONBOARD_CAPABILITY_ID, TRACE_COMMONS_PROFILE_SET_CAPABILITY_ID, TRACE_COMMONS_PROFILE_TOKEN_CAPABILITY_ID, TRACE_COMMONS_STATUS_CAPABILITY_ID, TRIGGER_CREATE_CAPABILITY_ID, TRIGGER_LIST_CAPABILITY_ID, TRIGGER_REMOVE_CAPABILITY_ID, diff --git a/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs b/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs index 033844a57ac..07a794cf433 100644 --- a/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs +++ b/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs @@ -30,8 +30,9 @@ use ironclaw_host_api::{ }; use ironclaw_host_runtime::{ CapabilitySurfaceVersion, HostRuntime, HostRuntimeServices, RuntimeCapabilityOutcome, - RuntimeCapabilityRequest, RuntimeFailureKind, TRACE_COMMONS_ONBOARD_CAPABILITY_ID, - TRACE_COMMONS_STATUS_CAPABILITY_ID, builtin_first_party_handlers, builtin_first_party_package, + RuntimeCapabilityRequest, RuntimeFailureKind, TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + TRACE_COMMONS_ONBOARD_CAPABILITY_ID, TRACE_COMMONS_STATUS_CAPABILITY_ID, + builtin_first_party_handlers, builtin_first_party_package, }; use ironclaw_network::{PolicyNetworkHttpEgress, ReqwestNetworkTransport}; use ironclaw_resources::InMemoryResourceGovernor; @@ -620,3 +621,200 @@ async fn onboard_unconfirmed_makes_no_network_call() { "no HTTP requests must reach the mock when confirmed=false" ); } + +/// Verify the consent gate: `account_login_link` with no `confirmed` must +/// return `consent_required=true` without making any network call. +#[tokio::test] +async fn account_login_link_requires_consent() { + let _base_dir = setup_base_dir(); + + let rt = runtime(); + + // Use allow_all_network_policy because the capability manifest declares + // EffectKind::Network; the host runtime stages the network grant before + // dispatching. The consent gate short-circuits inside the handler before + // making any actual network call. + let result = invoke_with_context( + &rt, + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + json!({}), + execution_context_with_network( + "user_login_link_consent", + "caller_login_link_consent", + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + allow_all_network_policy(), + ), + ) + .await + .expect("consent-gate dispatch must succeed (Ok envelope, not an Err)"); + + assert_eq!( + result["minted"], + json!(false), + "minted must be false when confirmed is absent" + ); + assert_eq!( + result["consent_required"], + json!(true), + "consent_required must be true when confirmed is absent" + ); + assert!( + result["message"].as_str().is_some_and(|m| !m.is_empty()), + "message must be non-empty" + ); +} + +/// Build a syntactically valid JWT string with the given header object. +/// The signature segment is a literal ASCII placeholder — JWT validation +/// in this codebase only inspects the header fields (alg, kid) and the +/// presence of a non-empty access_token, so this is sufficient for tests. +fn test_jwt_eddsa(kid: &str) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + let header = serde_json::json!({"alg": "EdDSA", "kid": kid}); + format!( + "{}.{}.signature", + URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()), + URL_SAFE_NO_PAD.encode(b"{}") + ) +} + +/// Verify the full dispatch chain for account_login_link: +/// 1. Onboard via mock server (writes local enrollment state). +/// 2. Agent invokes `builtin.trace_commons.account_login_link` with confirmed=true. +/// 3. The tool fetches a bearer token from `/v1/trace-upload-claim` (reqwest path). +/// 4. The tool POSTs to `/v1/account/login-links` (via host egress sink). +/// 5. The tool returns `minted=true` and the login URL. +#[tokio::test] +async fn account_login_link_through_dispatch() { + let _base_dir = setup_base_dir(); + + let claim_jwt = test_jwt_eddsa("e2e-key-1"); + let claim_jwt_for_mock = claim_jwt.clone(); + + // Spawn a mock server that handles all three routes needed: + // /v1/onboard — onboarding POST (standard mock response) + // /v1/trace-upload-claim — bearer-token issuer (reqwest path, not sink) + // /v1/account/login-links — the endpoint under test (via host egress sink) + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server binds"); + let addr = listener.local_addr().expect("mock server local addr"); + + let app = { + let claim_jwt_handler = claim_jwt_for_mock.clone(); + let addr_for_onboard = addr; + axum::Router::new() + .route( + "/v1/onboard", + axum::routing::post(move |axum::Json(body): axum::Json| { + let port = addr_for_onboard.port(); + async move { + // Echo the device_key_id from the submitted public key — + // mirrors spawn_mock_issuer's ECHO_DEVICE_KEY_ID logic. + let pubkey_b64 = body["device_public_key"] + .as_str() + .unwrap_or_default() + .to_string(); + let device_key_id = derive_device_key_id(&pubkey_b64) + .unwrap_or_else(|| "sha256:unknown".to_string()); + axum::Json(serde_json::json!({ + "schema_version": "trace_commons.onboard_response.v1", + "tenant_id": "tenant-login-link", + "ingest_url": "https://ingest.example.com", + "issuer_url": format!("http://127.0.0.1:{}/v1/trace-upload-claim", port), + "audience": "trace-commons-ingest", + "device_key_id": device_key_id, + })) + } + }), + ) + .route( + "/v1/trace-upload-claim", + axum::routing::post(move || { + let jwt = claim_jwt_handler.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(|| async { + axum::Json(serde_json::json!({ + "account_id": "acc123", + "url": "/account/login?code=testcode123" + })) + }), + ) + }; + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let invite_url = format!("http://127.0.0.1:{}/onboard#LOGINLINKE2E", addr.port()); + let rt = runtime(); + + // ── Step 1: Onboard ─────────────────────────────────────────────────────── + let onboard_result = invoke_with_context( + &rt, + TRACE_COMMONS_ONBOARD_CAPABILITY_ID, + json!({ + "invite_url": invite_url, + "include_message_text": false, + "include_tool_payloads": false, + "confirmed": true, + }), + execution_context_with_network( + "user_login_link_dispatch", + "caller_login_link_dispatch", + TRACE_COMMONS_ONBOARD_CAPABILITY_ID, + allow_all_network_policy(), + ), + ) + .await + .expect("onboard must succeed before login-link test"); + assert_eq!( + onboard_result["enrolled"], + json!(true), + "must be enrolled before testing login link" + ); + + // ── Step 2: Invoke account_login_link with confirmed=true ───────────────── + let result = invoke_with_context( + &rt, + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + json!({ "confirmed": true }), + execution_context_with_network( + "user_login_link_dispatch", + "caller_login_link_dispatch", + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID, + allow_all_network_policy(), + ), + ) + .await + .expect("account_login_link dispatch must succeed"); + + assert_eq!( + result["minted"], + json!(true), + "minted must be true on success; error_code={:?}, message={:?}", + result.get("error_code"), + result.get("message"), + ); + let url = result["url"].as_str().expect("url must be a string"); + assert!( + url.contains("/account/login?code="), + "url must contain the login-link path; got: {url}" + ); + assert_eq!( + result["account_id"], + json!("acc123"), + "account_id must match mock response" + ); +} From 46864371b554e26020ecc3f9163360f1eb46dd48 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 15:57:33 -0700 Subject: [PATCH 15/46] fix(host_runtime): route trace bearer via credential injection; restore FirstParty sensitive-header guard Commit 9e25d99d4 blanket-exempted all RuntimeKind::FirstParty requests from the egress sensitive-header and manual-credentials guards so the host-minted Trace Commons bearer could pass. builtin.http is also FirstParty but forwards model-supplied headers, so this let the model smuggle Authorization/Cookie/ x-api-key headers (or user:pass@ URLs) to allowlisted hosts. Revert the sanitize.rs exemption (guards now apply to ALL runtimes again) and deliver the trace bearer through the staged credential-injection path instead: the HostEgressContributionSink stages the minted token one-shot via RuntimeSecretMaterialStager and declares a StagedObligation Authorization-header injection, mirroring the SlackProtocolHttpEgress pattern. The stager is now exposed to first-party handlers via InvocationServices. Covers the profile_token, profile_set/community-profile, and account_login_link bearer paths. Regression tests: FirstParty + raw authorization header -> denied; FirstParty + user:pass@ URL -> denied. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/egress/sanitize.rs | 119 ++++++++++++++---- .../ironclaw_host_runtime/src/first_party.rs | 1 + .../src/first_party_tools/mod.rs | 7 +- .../src/first_party_tools/profile_set.rs | 2 + .../src/first_party_tools/trace_commons.rs | 70 +++++++++-- .../src/invocation_services.rs | 37 +++++- crates/ironclaw_host_runtime/src/services.rs | 3 +- .../tests/trace_commons_dispatch_e2e.rs | 2 +- 8 files changed, 199 insertions(+), 42 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/egress/sanitize.rs b/crates/ironclaw_host_runtime/src/egress/sanitize.rs index d34090c5aa7..ecd02022220 100644 --- a/crates/ironclaw_host_runtime/src/egress/sanitize.rs +++ b/crates/ironclaw_host_runtime/src/egress/sanitize.rs @@ -1,6 +1,6 @@ use ironclaw_host_api::{ - RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeKind, - is_sensitive_runtime_request_header, is_sensitive_runtime_response_header, + RuntimeHttpEgressError, RuntimeHttpEgressRequest, is_sensitive_runtime_request_header, + is_sensitive_runtime_response_header, }; use ironclaw_network::{NetworkHttpResponse, percent_decode_url_component_lossy}; use ironclaw_safety::{LeakDetector, http_parts_contain_manual_credentials, redact_exact_values}; @@ -9,31 +9,32 @@ pub(super) fn validate_runtime_request( request: &RuntimeHttpEgressRequest, leak_detector: &LeakDetector, ) -> Result<(), RuntimeHttpEgressError> { - // First-party requests are host-internal: the host itself constructs them - // and is trusted to add credential headers (e.g. Authorization: Bearer). - // Skip the sensitive-header and manual-credentials guards that exist to - // prevent untrusted plugin runtimes (WASM/MCP/Script) from smuggling - // credentials into outbound requests. - if request.runtime != RuntimeKind::FirstParty { - if let Some((_name, _)) = request - .headers - .iter() - .find(|(name, _)| is_sensitive_runtime_request_header(name)) - { - return Err(RuntimeHttpEgressError::Request { - reason: "sensitive_header_denied".to_string(), - request_bytes: 0, - response_bytes: 0, - }); - } + // Outbound credentials must flow through the staged credential-injection + // path (`credential_injections`), never as raw runtime/model-supplied + // headers or `user:pass@` URLs — for ALL runtimes, including FirstParty. + // `builtin.http` is FirstParty but takes model-supplied headers, so + // exempting FirstParty here would let the model smuggle Authorization / + // Cookie / x-api-key headers to allowlisted hosts. Host-minted credentials + // (e.g. the Trace Commons bearer) are injected AFTER this guard via the + // stager + `apply_credential_injections`, so they are not present here. + if let Some((_name, _)) = request + .headers + .iter() + .find(|(name, _)| is_sensitive_runtime_request_header(name)) + { + return Err(RuntimeHttpEgressError::Request { + reason: "sensitive_header_denied".to_string(), + request_bytes: 0, + response_bytes: 0, + }); + } - if runtime_request_contains_manual_credentials(request) { - return Err(RuntimeHttpEgressError::Request { - reason: "manual_credentials_denied".to_string(), - request_bytes: 0, - response_bytes: 0, - }); - } + if runtime_request_contains_manual_credentials(request) { + return Err(RuntimeHttpEgressError::Request { + reason: "manual_credentials_denied".to_string(), + request_bytes: 0, + response_bytes: 0, + }); } scan_runtime_url_for_leaks(leak_detector, &request.url)?; @@ -213,6 +214,72 @@ pub(super) fn sanitize_runtime_response( #[cfg(test)] mod tests { use super::*; + use ironclaw_host_api::{ + CapabilityId, InvocationId, NetworkMethod, NetworkPolicy, ResourceScope, RuntimeKind, + UserId, + }; + + fn request_with_header(runtime: RuntimeKind, header: (&str, &str)) -> RuntimeHttpEgressRequest { + RuntimeHttpEgressRequest { + runtime, + scope: ResourceScope::local_default(UserId::new("user1").unwrap(), InvocationId::new()) + .unwrap(), + capability_id: CapabilityId::new("builtin.http").unwrap(), + method: NetworkMethod::Get, + url: "https://api.example.test/v1/run".to_string(), + headers: vec![(header.0.to_string(), header.1.to_string())], + body: Vec::new(), + network_policy: NetworkPolicy { + allowed_targets: vec![], + deny_private_ip_ranges: true, + max_egress_bytes: Some(4096), + }, + credential_injections: vec![], + response_body_limit: Some(4096), + save_body_to: None, + timeout_ms: None, + } + } + + #[test] + fn validate_runtime_request_denies_sensitive_header_for_first_party_runtime() { + // Regression: FirstParty must NOT be exempt from the sensitive-header + // guard. `builtin.http` is FirstParty but takes model-supplied headers, + // so exempting it would let the model smuggle Authorization to an + // allowlisted host. Host-minted credentials flow through the staged + // credential-injection path instead, after this guard. + let detector = LeakDetector::new(); + let request = request_with_header( + RuntimeKind::FirstParty, + ("authorization", "Bearer attacker"), + ); + + let error = validate_runtime_request(&request, &detector) + .expect_err("first-party sensitive header must be denied"); + + assert!(matches!( + error, + RuntimeHttpEgressError::Request { ref reason, .. } + if reason == "sensitive_header_denied" + )); + } + + #[test] + fn validate_runtime_request_denies_manual_url_credentials_for_first_party_runtime() { + let detector = LeakDetector::new(); + let mut request = + request_with_header(RuntimeKind::FirstParty, ("accept", "application/json")); + request.url = "https://user:pass@api.example.test/v1/run".to_string(); + + let error = validate_runtime_request(&request, &detector) + .expect_err("first-party manual url credentials must be denied"); + + assert!(matches!( + error, + RuntimeHttpEgressError::Request { ref reason, .. } + if reason == "manual_credentials_denied" + )); + } #[test] fn scan_decoded_url_for_leaks_allows_unparseable_encoded_url() { diff --git a/crates/ironclaw_host_runtime/src/first_party.rs b/crates/ironclaw_host_runtime/src/first_party.rs index ee98fed2733..01ca628ef64 100644 --- a/crates/ironclaw_host_runtime/src/first_party.rs +++ b/crates/ironclaw_host_runtime/src/first_party.rs @@ -75,6 +75,7 @@ impl FirstPartyCapabilityRequest { filesystem: Arc::new(ironclaw_filesystem::InMemoryBackend::new()), runtime_http_egress, tool_call_http_egress: None, + runtime_secret_material_stager: None, process: Arc::new(crate::LocalHostProcessPort::new()), secret_store: None, audit_sink: None, diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs b/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs index 4503c9933a5..121f15c8bd0 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/mod.rs @@ -423,9 +423,10 @@ impl FirstPartyCapabilityHandler for BuiltinFirstPartyTools { TRACE_COMMONS_PROFILE_SET_CAPABILITY_ID => { (trace_commons::dispatch_profile_set(&request).await?, None) } - TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID => { - (trace_commons::dispatch_account_login_link(&request).await?, None) - } + TRACE_COMMONS_ACCOUNT_LOGIN_LINK_CAPABILITY_ID => ( + trace_commons::dispatch_account_login_link(&request).await?, + None, + ), capability_id => { let Some(metadata) = coding_capability_metadata(capability_id) else { return Err(FirstPartyCapabilityError::new( diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/profile_set.rs b/crates/ironclaw_host_runtime/src/first_party_tools/profile_set.rs index e4ff5f3d115..cfd8ab943e5 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/profile_set.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/profile_set.rs @@ -248,6 +248,7 @@ mod tests { filesystem: fs, runtime_http_egress: None, tool_call_http_egress: None, + runtime_secret_material_stager: None, process: Arc::new(LocalHostProcessPort::new()), secret_store: None, audit_sink: None, @@ -307,6 +308,7 @@ mod tests { filesystem: Arc::clone(&req1.services.filesystem), runtime_http_egress: None, tool_call_http_egress: None, + runtime_secret_material_stager: None, process: Arc::new(LocalHostProcessPort::new()), secret_store: None, audit_sink: None, diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs index aca5b6ec200..88f8be760ae 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs @@ -17,8 +17,9 @@ use futures_util::FutureExt as _; use ironclaw_extensions::{CapabilityManifest, ExtensionError}; use ironclaw_host_api::{ CapabilityId, EffectKind, NetworkMethod, NetworkPolicy, PermissionMode, ResourceEstimate, - ResourceProfile, ResourceScope, RuntimeDispatchErrorKind, RuntimeHttpEgress, - RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeKind, + ResourceProfile, ResourceScope, RuntimeCredentialInjection, RuntimeCredentialSource, + RuntimeCredentialTarget, RuntimeDispatchErrorKind, RuntimeHttpEgress, RuntimeHttpEgressError, + RuntimeHttpEgressRequest, RuntimeKind, SecretHandle, }; use ironclaw_reborn_traces::contribution::{ COMMUNITY_PROFILE_BIO_MAX_BYTES, COMMUNITY_PROFILE_HANDLE_MAX_CHARS, @@ -26,18 +27,26 @@ use ironclaw_reborn_traces::contribution::{ ContributionHttpRequest, ContributionHttpResponse, ContributionHttpSink, ProfileAttributionToken, StandingTraceContributionPolicy, TraceCreditReport, TraceUploadAuthMode, mint_account_login_link_via_sink, - mint_profile_attribution_token_for_scope_via_sink, - read_trace_policy_for_scope, set_community_profile_for_scope_via_sink, - trace_contribution_dir_for_scope, trace_scope_key, + mint_profile_attribution_token_for_scope_via_sink, read_trace_policy_for_scope, + set_community_profile_for_scope_via_sink, trace_contribution_dir_for_scope, trace_scope_key, }; use ironclaw_reborn_traces::onboarding::{ OnboardConsents, OnboardError, OnboardHttpResponse, OnboardOutcome, OnboardingHttpSink, protocol::OnboardErrorCode, }; +use ironclaw_secrets::SecretMaterial; use serde_json::{Value, json}; use crate::FirstPartyCapabilityError; use crate::FirstPartyCapabilityRequest; +use crate::RuntimeSecretMaterialStager; + +/// Secret handle under which the host-minted Trace Commons bearer token is +/// staged for one-shot credential injection into the outbound Authorization +/// header. The token is delivered through the staged credential-injection path +/// (stager + `apply_credential_injections`), never as a raw request header, so +/// the egress sensitive-header guard still applies to model-supplied headers. +const TRACE_COMMONS_BEARER_HANDLE: &str = "trace_commons_bearer"; /// Maximum onboarding response body accepted (64 KiB), mirroring the cap the /// onboarding module enforces for its default sink. @@ -368,6 +377,10 @@ struct HostEgressContributionSink { egress: Arc, scope: ResourceScope, capability_id: CapabilityId, + /// One-shot stager used to deliver the host-minted Trace Commons bearer + /// through the egress credential-injection path. `None` only on + /// non-network-egress invocations, where a bearer would never be present. + secret_stager: Option, } #[async_trait] @@ -381,14 +394,47 @@ impl ContributionHttpSink for HostEgressContributionSink { ContributionHttpMethod::Put => NetworkMethod::Put, ContributionHttpMethod::Delete => NetworkMethod::Delete, }; - let mut headers = vec![ + let headers = vec![ ("accept".to_string(), "application/json".to_string()), ("content-type".to_string(), "application/json".to_string()), ]; - // Only attach the bearer header when a token is present; the raw token - // never appears in any error path below. + // The host-minted bearer is a credential: it MUST flow through the + // staged credential-injection path, never as a raw Authorization + // header. Writing it into `headers` here would (a) be denied by the + // egress sensitive-header guard and (b) bypass the leased-secret + // redaction the injection path provides. Stage the token one-shot, + // then declare a `StagedObligation` injection targeting the + // Authorization header; `apply_credential_injections` consumes it + // after the guard runs. + let mut credential_injections = Vec::new(); if let Some(token) = req.bearer_token { - headers.push(("authorization".to_string(), format!("Bearer {token}"))); + let Some(secret_stager) = self.secret_stager.as_ref() else { + return Err(ContributionHttpError::new( + "trace bearer staging is unavailable", + )); + }; + let handle = SecretHandle::new(TRACE_COMMONS_BEARER_HANDLE) + .map_err(|_| ContributionHttpError::new("invalid trace bearer handle"))?; + secret_stager + .stage_secret_material_once( + &self.scope, + &self.capability_id, + &handle, + SecretMaterial::from(token), + ) + .await + .map_err(|_| ContributionHttpError::new("trace bearer could not be staged"))?; + credential_injections.push(RuntimeCredentialInjection { + handle, + source: RuntimeCredentialSource::StagedObligation { + capability_id: self.capability_id.clone(), + }, + target: RuntimeCredentialTarget::Header { + name: "authorization".to_string(), + prefix: Some("Bearer ".to_string()), + }, + required: true, + }); } let request = RuntimeHttpEgressRequest { runtime: RuntimeKind::FirstParty, @@ -402,7 +448,7 @@ impl ContributionHttpSink for HostEgressContributionSink { // the grant obligation for this scope/capability; this request field // is the ignored fallback on that path (matches http::dispatch). network_policy: NetworkPolicy::default(), - credential_injections: Vec::new(), + credential_injections, response_body_limit: Some(req.response_body_limit), // The response is parsed inline, never persisted to a mount. save_body_to: None, @@ -729,6 +775,7 @@ pub(super) async fn dispatch_profile_token( egress, scope: request.scope.clone(), capability_id: request.capability_id.clone(), + secret_stager: request.services.runtime_secret_material_stager.clone(), }; match mint_profile_attribution_token_for_scope_via_sink(Some(scope.as_str()), &sink).await { Ok(token) => match persist_profile_token(&scope, &token) { @@ -911,6 +958,7 @@ pub(super) async fn dispatch_profile_set( egress, scope: request.scope.clone(), capability_id: request.capability_id.clone(), + secret_stager: request.services.runtime_secret_material_stager.clone(), }; match set_community_profile_for_scope_via_sink( Some(scope.as_str()), @@ -1040,6 +1088,7 @@ pub(super) async fn dispatch_account_login_link( egress, scope: request.scope.clone(), capability_id: request.capability_id.clone(), + secret_stager: request.services.runtime_secret_material_stager.clone(), }; match mint_account_login_link_via_sink( request.scope.tenant_id.as_str(), @@ -1147,6 +1196,7 @@ mod tests { filesystem: Arc::new(LocalFilesystem::new()), runtime_http_egress: None, tool_call_http_egress: None, + runtime_secret_material_stager: None, process: Arc::new(NoopProcessPort), secret_store: None, audit_sink: None, diff --git a/crates/ironclaw_host_runtime/src/invocation_services.rs b/crates/ironclaw_host_runtime/src/invocation_services.rs index bce2b5916e0..f8f420c8aa8 100644 --- a/crates/ironclaw_host_runtime/src/invocation_services.rs +++ b/crates/ironclaw_host_runtime/src/invocation_services.rs @@ -23,7 +23,7 @@ use ironclaw_host_api::{ use ironclaw_secrets::SecretStore; use thiserror::Error; -use crate::{ExecutionPlan, RuntimeProcessPort}; +use crate::{ExecutionPlan, RuntimeProcessPort, RuntimeSecretMaterialStager}; /// Concrete host API bindings for an already-authorized invocation. /// @@ -35,6 +35,15 @@ pub struct InvocationServices { pub filesystem: Arc, pub runtime_http_egress: Option>, pub tool_call_http_egress: Option>, + /// One-shot stager for host-held/host-minted credential material. + /// + /// First-party handlers that already hold a trusted secret (e.g. a + /// host-minted bearer token) stage it through this port and add a + /// matching `StagedObligation` credential injection, so the secret flows + /// through the egress credential-injection path instead of being written + /// into raw request headers. Present only when the invocation may perform + /// network egress. + pub runtime_secret_material_stager: Option, pub process: Arc, pub secret_store: Option>, pub audit_sink: Option>, @@ -54,6 +63,13 @@ impl fmt::Debug for InvocationServices { "tool_call_http_egress", &self.tool_call_http_egress.as_ref().map(|_| "[REDACTED]"), ) + .field( + "runtime_secret_material_stager", + &self + .runtime_secret_material_stager + .as_ref() + .map(|_| "[REDACTED]"), + ) .field("process", &"[REDACTED]") .field( "secret_store", @@ -141,6 +157,7 @@ pub struct LocalInvocationServicesResolver { filesystem: Arc, runtime_http_egress: Option>, tool_call_http_egress: Option>, + runtime_secret_material_stager: Option, process: Arc, tenant_sandbox_process: Option>, secret_store: Option>, @@ -158,6 +175,7 @@ impl LocalInvocationServicesResolver { filesystem, runtime_http_egress, tool_call_http_egress: None, + runtime_secret_material_stager: None, process, tenant_sandbox_process: None, secret_store, @@ -165,6 +183,19 @@ impl LocalInvocationServicesResolver { } } + /// Supplies the one-shot host secret-material stager so first-party + /// handlers can deliver host-held/host-minted credentials through the + /// egress credential-injection path. Must wrap the same + /// `RuntimeSecretInjectionStore` the configured `runtime_http_egress` + /// reads from, or staged material will not be found at injection time. + pub fn with_runtime_secret_material_stager( + mut self, + stager: Option, + ) -> Self { + self.runtime_secret_material_stager = stager; + self + } + pub fn with_tool_call_http_egress( mut self, tool_call_http_egress: Option>, @@ -261,6 +292,10 @@ impl InvocationServicesResolver for LocalInvocationServicesResolver { .requires_network .then(|| self.tool_call_http_egress.clone()) .flatten(), + runtime_secret_material_stager: plan + .requires_network + .then(|| self.runtime_secret_material_stager.clone()) + .flatten(), process, secret_store: if plan.requires_secret { self.secret_store.clone() diff --git a/crates/ironclaw_host_runtime/src/services.rs b/crates/ironclaw_host_runtime/src/services.rs index 36176ba96fe..383b5f7f355 100644 --- a/crates/ironclaw_host_runtime/src/services.rs +++ b/crates/ironclaw_host_runtime/src/services.rs @@ -397,7 +397,8 @@ where Arc::clone(&self.process_port), self.secret_store.clone(), ) - .with_tool_call_http_egress(tool_call_http_egress(&self.tool_call_http_egress)); + .with_tool_call_http_egress(tool_call_http_egress(&self.tool_call_http_egress)) + .with_runtime_secret_material_stager(Some(self.runtime_secret_material_stager())); if let Some(audit_sink) = &self.audit_sink { invocation_services_resolver = invocation_services_resolver.with_audit_sink(Arc::clone(audit_sink)); diff --git a/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs b/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs index 07a794cf433..7831f2b7540 100644 --- a/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs +++ b/crates/ironclaw_host_runtime/tests/trace_commons_dispatch_e2e.rs @@ -669,8 +669,8 @@ async fn account_login_link_requires_consent() { /// in this codebase only inspects the header fields (alg, kid) and the /// presence of a non-empty access_token, so this is sufficient for tests. fn test_jwt_eddsa(kid: &str) -> String { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = serde_json::json!({"alg": "EdDSA", "kid": kid}); format!( "{}.{}.signature", From 71d6155d50609c58a7b668642c0c829f28be703c Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 16:08:25 -0700 Subject: [PATCH 16/46] feat(traces): fetch_account_traces_via_sink (GET /v1/account/traces, per-user) - Add ContributionHttpMethod::Get variant; update all exhaustive match sites in ironclaw_reborn_traces and HostEgressContributionSink in ironclaw_host_runtime. - Extract account_api_base_url() shared helper; account_login_links_url and new account_traces_url both delegate to it (DRY). - Add AccountTraceItem (Debug, Clone, Serialize, Deserialize; serde defaults). - Add fetch_account_traces_via_sink / fetch_account_traces_inner mirroring mint_account_login_link pattern: unenrolled -> Ok(vec![]), non-2xx -> Ok(vec![]), transport error -> Err. - Tests: hermetic axum mock (GET /v1/account/traces), unenrolled empty-list, URL shape with/without limit. Co-Authored-By: Claude Sonnet 4.6 --- .../src/first_party_tools/trace_commons.rs | 1 + .../src/contribution.rs | 259 +++++++++++++++++- 2 files changed, 254 insertions(+), 6 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs index 88f8be760ae..d75de57761d 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs @@ -390,6 +390,7 @@ impl ContributionHttpSink for HostEgressContributionSink { req: ContributionHttpRequest, ) -> Result { let method = match req.method { + ContributionHttpMethod::Get => NetworkMethod::Get, ContributionHttpMethod::Post => NetworkMethod::Post, ContributionHttpMethod::Put => NetworkMethod::Put, ContributionHttpMethod::Delete => NetworkMethod::Delete, diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 86856192a3d..8a07e1d2d1d 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -5240,6 +5240,7 @@ pub trait ContributionHttpSink: Send + Sync { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContributionHttpMethod { + Get, Post, Put, Delete, @@ -5894,6 +5895,7 @@ async fn community_profile_http_client( fn community_profile_method_label(method: ContributionHttpMethod) -> &'static str { match method { + ContributionHttpMethod::Get => "GET", ContributionHttpMethod::Post => "POST", ContributionHttpMethod::Put => "PUT", ContributionHttpMethod::Delete => "DELETE", @@ -5952,6 +5954,7 @@ async fn execute_community_profile_request( } else { let client = community_profile_http_client(policy, &url).await?; let reqwest_method = match method { + ContributionHttpMethod::Get => reqwest::Method::GET, ContributionHttpMethod::Post => reqwest::Method::POST, ContributionHttpMethod::Put => reqwest::Method::PUT, ContributionHttpMethod::Delete => reqwest::Method::DELETE, @@ -6009,11 +6012,11 @@ pub struct AccountLoginLink { pub url: String, } -/// Derive the account-login-links URL from the configured upload-claim issuer -/// URL. The login-links service lives at the same origin as the issuer; only -/// the path differs: strip `/v1/trace-upload-claim`, append -/// `/v1/account/login-links`. -fn account_login_links_url(policy: &StandingTraceContributionPolicy) -> anyhow::Result { +/// Extract the API base URL (origin) from the configured upload-claim issuer +/// URL by stripping the `/v1/trace-upload-claim` suffix. Other account API +/// endpoints (`/v1/account/login-links`, `/v1/account/traces`, …) are built on +/// top of this shared origin so the derivation is not duplicated. +fn account_api_base_url(policy: &StandingTraceContributionPolicy) -> anyhow::Result { let issuer_url = policy .upload_token_issuer_url .as_deref() @@ -6028,7 +6031,28 @@ fn account_login_links_url(policy: &StandingTraceContributionPolicy) -> anyhow:: "upload_token_issuer_url does not end in /v1/trace-upload-claim: {issuer_url}" ) })?; - Ok(format!("{base}/v1/account/login-links")) + Ok(base.to_string()) +} + +/// Derive the account-login-links URL from the configured upload-claim issuer +/// URL. The login-links service lives at the same origin as the issuer; only +/// the path differs: strip `/v1/trace-upload-claim`, append +/// `/v1/account/login-links`. +fn account_login_links_url(policy: &StandingTraceContributionPolicy) -> anyhow::Result { + Ok(format!("{}/v1/account/login-links", account_api_base_url(policy)?)) +} + +/// Derive the account-traces URL from the configured upload-claim issuer URL. +/// Strip `/v1/trace-upload-claim`, append `/v1/account/traces`. +fn account_traces_url( + policy: &StandingTraceContributionPolicy, + limit: Option, +) -> anyhow::Result { + let base = account_api_base_url(policy)?; + match limit { + Some(n) => Ok(format!("{base}/v1/account/traces?limit={n}")), + None => Ok(format!("{base}/v1/account/traces")), + } } /// Mint a one-time account login link for the given `(tenant_id, user_id)`. @@ -6124,6 +6148,98 @@ async fn mint_account_login_link_inner( }) } +// ── Trace Commons account traces ────────────────────────────────────────────── + +/// A single submitted trace record as returned by `GET /v1/account/traces`. +/// Only the fields the UI needs are projected here; unknown server fields are +/// ignored via `#[serde(default)]` and `#[serde(deny_unknown_fields)]` is +/// deliberately omitted. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AccountTraceItem { + #[serde(default)] + pub submission_id: String, + #[serde(default)] + pub status: String, + #[serde(default)] + pub credit_points_pending: f32, + #[serde(default)] + pub credit_points_final: Option, + #[serde(default)] + pub received_at: Option, +} + +/// Fetch the list of submitted traces for the given `(tenant_id, user_id)` via +/// the caller-supplied `sink` (host egress on the agent path). +/// +/// - Resolves the user's Trace Commons credentials; returns `Ok(vec![])` when +/// the user is not enrolled (lenient zero-state, not an error). +/// - Mints the per-user bearer via `DefaultTraceUploadCredentialProvider` +/// (identical to how submission and profile-attribution flows do it). +/// - GETs `/v1/account/traces?limit=N` and parses the JSON array into +/// `Vec`. Non-2xx for an unenrolled/empty case also +/// returns `Ok(vec![])`. Transport failures return `Err`. +pub async fn fetch_account_traces_via_sink( + tenant_id: &str, + user_id: &str, + limit: Option, + sink: &dyn ContributionHttpSink, +) -> anyhow::Result> { + fetch_account_traces_inner( + ironclaw_common::paths::ironclaw_base_dir().as_path(), + tenant_id, + user_id, + limit, + sink, + ) + .await +} + +/// Dir-parameterised core for [`fetch_account_traces_via_sink`]. +/// Accepts an explicit `base_dir` so tests can supply an isolated tempdir. +async fn fetch_account_traces_inner( + base_dir: &std::path::Path, + tenant_id: &str, + user_id: &str, + limit: Option, + sink: &dyn ContributionHttpSink, +) -> anyhow::Result> { + let resolution = match resolve_trace_credentials_at(base_dir, tenant_id, user_id)? { + Some(r) => r, + None => return Ok(vec![]), + }; + + let scope_dir = if resolution.subject.is_some() { + trace_contribution_dir_for_scope_at(base_dir, None) + } else { + trace_contribution_dir_for_scope_at(base_dir, Some(resolution.state_scope.as_str())) + }; + + let context = TraceUploadClaimContext::for_account(resolution.subject.clone()) + .with_scope_dir(scope_dir); + let provider = DefaultTraceUploadCredentialProvider; + let bearer = provider + .bearer_token(&resolution.policy, &context, false) + .await?; + let url = account_traces_url(&resolution.policy, limit)?; + let response = sink + .execute(ContributionHttpRequest { + method: ContributionHttpMethod::Get, + url, + bearer_token: Some(bearer), + json_body: None, + response_body_limit: TRACE_UPLOAD_CLAIM_MAX_RESPONSE_BYTES as u64, + timeout_ms: 10_000, + }) + .await + .map_err(|e| anyhow::anyhow!("account traces request failed: {e}"))?; + if !(200..300).contains(&response.status) { + return Ok(vec![]); + } + let items: Vec = serde_json::from_slice(&response.body) + .context("account traces response was not a valid JSON array")?; + Ok(items) +} + #[cfg(test)] tokio::task_local! { @@ -14941,6 +15057,7 @@ mod tests { req: ContributionHttpRequest, ) -> Result { let method = match req.method { + ContributionHttpMethod::Get => reqwest::Method::GET, ContributionHttpMethod::Post => reqwest::Method::POST, ContributionHttpMethod::Put => reqwest::Method::PUT, ContributionHttpMethod::Delete => reqwest::Method::DELETE, @@ -15115,4 +15232,134 @@ mod tests { let url = account_login_links_url(&policy).expect("valid issuer must succeed"); assert_eq!(url, "https://api.example.com/v1/account/login-links"); } + + #[test] + fn account_traces_url_correct_with_and_without_limit() { + let policy = StandingTraceContributionPolicy { + upload_token_issuer_url: Some( + "https://api.example.com/v1/trace-upload-claim".to_string(), + ), + ..Default::default() + }; + let url_no_limit = account_traces_url(&policy, None).expect("no-limit must succeed"); + assert_eq!(url_no_limit, "https://api.example.com/v1/account/traces"); + let url_with_limit = + account_traces_url(&policy, Some(50)).expect("limit=50 must succeed"); + assert_eq!( + url_with_limit, + "https://api.example.com/v1/account/traces?limit=50" + ); + } + + #[tokio::test] + async fn fetch_account_traces_returns_user_submissions() { + // A syntactically valid JWT that passes validate_trace_upload_claim_response. + let claim_jwt = test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "test-key-1"})); + let claim_jwt_for_mock = claim_jwt.clone(); + + // ── mock server ────────────────────────────────────────────────────── + // Two endpoints: + // /v1/trace-upload-claim — upload-claim issuer (DeviceKey mode) + // /v1/account/traces — the endpoint under test (via sink) + 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/traces", + axum::routing::get(|| async { + axum::Json(serde_json::json!([ + { + "submission_id": "s1", + "status": "accepted", + "credit_points_pending": 1.0, + "credit_points_final": 1.0, + "received_at": "2026-06-25T00:00:00Z" + } + ])) + }), + ); + + 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; + }); + + // ── isolated tempdir ───────────────────────────────────────────────── + let base = tempfile::tempdir().unwrap(); + + // Instance policy (scope None) — enables instance enrollment so + // resolve_trace_credentials_at returns a per-user subject. + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), + upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from([ + "127.0.0.1".to_string(), + ]), + upload_token_tenant_id: Some("tenant-dev".to_string()), + upload_token_audience: Some("trace-commons-ingest".to_string()), + ..Default::default() + }; + write_trace_policy_for_scope_at(base.path(), None, &policy) + .expect("instance policy writes"); + + // Generate and promote a device key at the instance scope dir so + // DeviceKey auth mode can sign the workload JWT without a network call. + let instance_dir = trace_contribution_dir_for_scope_at(base.path(), None); + let pending = + crate::onboarding::DeviceKeypair::load_or_generate_pending(&instance_dir, "testhash") + .unwrap(); + pending.promote(&instance_dir, "tenant-dev").unwrap(); + + // ── call under test ────────────────────────────────────────────────── + let sink = ReqwestContributionSink; + let items = + fetch_account_traces_inner(base.path(), "tenant-dev", "alice", Some(50), &sink) + .await + .unwrap(); + + // ── assertions ─────────────────────────────────────────────────────── + assert_eq!(items.len(), 1, "expected exactly one trace item"); + assert_eq!(items[0].submission_id, "s1"); + assert_eq!(items[0].status, "accepted"); + assert!( + (items[0].credit_points_pending - 1.0).abs() < f32::EPSILON, + "credit_points_pending must be 1.0" + ); + assert_eq!( + items[0].credit_points_final, + Some(1.0), + "credit_points_final must be Some(1.0)" + ); + assert_eq!( + items[0].received_at.as_deref(), + Some("2026-06-25T00:00:00Z") + ); + } + + #[tokio::test] + async fn fetch_account_traces_returns_empty_when_not_enrolled() { + let base = tempfile::tempdir().unwrap(); + // No policy written — resolver returns None → lenient Ok(vec![]). + let sink = ReqwestContributionSink; + let items = + fetch_account_traces_inner(base.path(), "tenant-dev", "alice", None, &sink) + .await + .unwrap(); + assert!(items.is_empty(), "unenrolled user must return empty list"); + } } From 9650d6b4700582286cea91ac01d260987fa9d574 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 16:21:16 -0700 Subject: [PATCH 17/46] feat(reborn): trace_account_traces facade method + wire types Adds RebornAccountTrace / RebornAccountTracesResponse wire types and a trace_account_traces default method on RebornServicesApi, mirroring the trace_credits egress pattern (crate-local hardened reqwest, no host-egress sink). Also adds fetch_account_traces (direct path) to ironclaw_reborn_traces::contribution so the facade can fetch server traces without coupling to RuntimeHttpEgress. Co-Authored-By: Claude Sonnet 4.6 --- crates/ironclaw_product_workflow/src/lib.rs | 4 +- .../src/reborn_services.rs | 29 +++++++- .../src/reborn_services/trace_credits.rs | 68 ++++++++++++++++- .../src/contribution.rs | 73 +++++++++++++++++++ 4 files changed, 170 insertions(+), 4 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index c7b14fae1e9..2135e2fa048 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -186,8 +186,8 @@ pub use reborn_services::{ RebornSkillContentResponse, RebornSkillInfo, RebornSkillListResponse, RebornSkillSearchResponse, RebornSkillSourceKind, RebornSkillTrustLevel, RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, - RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, - RebornTraceHoldAuthorizeResponse, SetActiveLlmRequest, SkillsProductFacade, + RebornAccountTrace, RebornAccountTracesResponse, RebornTimelineRequest, RebornTimelineResponse, + RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse, SetActiveLlmRequest, SkillsProductFacade, StaticConnectableChannelsProductFacade, StaticOperatorStatusService, TriggerRunThreadScope, UnsupportedAutomationProductFacade, UnsupportedOperatorLogsService, UnsupportedOperatorServiceLifecycleService, UnsupportedOperatorStatusService, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 00401f56793..1f75712dd1d 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -69,7 +69,10 @@ mod trace_credits; mod types; pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind}; -pub use trace_credits::{RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse}; +pub use trace_credits::{ + RebornAccountTrace, RebornAccountTracesResponse, RebornTraceCreditsResponse, + RebornTraceHoldAuthorizeResponse, +}; pub use llm_config::{ CodexLoginStart, LlmActiveSelection, LlmConfigService, LlmConfigServiceError, @@ -1069,6 +1072,30 @@ pub trait RebornServicesApi: Send + Sync { .map_err(RebornServicesError::internal_from) } + /// Read-only list of the authenticated caller's submitted Trace Commons + /// traces, fetched directly from the server (not a local view). + /// + /// The scope is always derived from the authenticated caller's tenant + + /// user id — never from request input. A user who is not enrolled gets the + /// unenrolled zero-state (`{ enrolled: false, traces: [] }`), never an + /// error. Transport failures surface as an internal error. + /// + /// The default body is the production implementation using the crate-local + /// hardened reqwest path (no host-egress sink), so impls (including test + /// fakes) only override this when they need a non-standard fetch path. + async fn trace_account_traces( + &self, + caller: WebUiAuthenticatedCaller, + ) -> Result { + let actor = caller.actor(); + trace_credits::account_traces_for_user( + caller.tenant_id.as_str(), + actor.user_id.as_str(), + ) + .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 f7d613d3ae0..ca448615659 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/trace_credits.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/trace_credits.rs @@ -9,7 +9,8 @@ use chrono::{DateTime, Utc}; use ironclaw_reborn_traces::contribution::{ - authorize_manual_review_hold_for_scope, read_trace_policy_for_scope, scoped_credit_view, + authorize_manual_review_hold_for_scope, fetch_account_traces, read_trace_policy_for_scope, + resolve_trace_credentials, scoped_credit_view, }; use serde::{Deserialize, Serialize}; @@ -64,6 +65,32 @@ pub struct RebornTraceHold { pub reason: String, } +/// One submitted trace record as returned by the Trace Commons server. +/// Carries only the fields the UI needs; unknown server fields are ignored. +#[derive(Debug, Clone, Serialize)] +pub struct RebornAccountTrace { + pub submission_id: String, + pub status: String, + pub pending_credit: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_credit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received_at: Option, +} + +/// Read-only list of the caller's submitted Trace Commons traces. +/// +/// `enrolled` mirrors the caller's contribution-policy enrollment status +/// (same semantics as [`RebornTraceCreditsResponse::enrolled`]). +/// `traces` is the server-returned list in reverse-chronological order; +/// an empty list is normal for an enrolled user who has not yet submitted +/// any traces. +#[derive(Debug, Clone, Serialize)] +pub struct RebornAccountTracesResponse { + pub enrolled: bool, + pub traces: Vec, +} + /// Result of authorizing a held trace for submission. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RebornTraceHoldAuthorizeResponse { @@ -73,6 +100,45 @@ pub struct RebornTraceHoldAuthorizeResponse { pub authorized: bool, } +/// Fetch the caller-scoped submitted traces from the Trace Commons server. +/// +/// Uses the crate-local hardened reqwest path (no host-egress sink) — the same +/// network lane as the rest of the WebUI / facade surface. The `enrolled` flag +/// is set from `resolve_trace_credentials`; a user who is not enrolled gets the +/// unenrolled zero-state (`enrolled: false`, empty list) rather than an error. +/// Transport failures surface as `Err` so the caller can return a sanitized 500. +pub(super) async fn account_traces_for_user( + tenant_id: &str, + user_id: &str, +) -> Result { + let enrolled = resolve_trace_credentials(tenant_id, user_id) + .map_err(|e| format!("{e:#}"))? + .is_some(); + if !enrolled { + return Ok(RebornAccountTracesResponse { + enrolled: false, + traces: vec![], + }); + } + let items = fetch_account_traces(tenant_id, user_id, None) + .await + .map_err(|e| format!("{e:#}"))?; + let traces = items + .into_iter() + .map(|item| RebornAccountTrace { + submission_id: item.submission_id, + status: item.status, + pending_credit: item.credit_points_pending, + final_credit: item.credit_points_final, + received_at: item.received_at, + }) + .collect(); + Ok(RebornAccountTracesResponse { + enrolled: true, + traces, + }) +} + /// Authorize the caller-scoped held manual-review trace for submission. /// /// The trace `scope` is the caller's tenant-scoped key (see diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 8a07e1d2d1d..3b54ed085a0 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -6194,6 +6194,79 @@ pub async fn fetch_account_traces_via_sink( .await } +/// Fetch the list of submitted traces for the given `(tenant_id, user_id)` using +/// the crate-local hardened reqwest client (the direct/CLI path, no host-egress +/// sink required). +/// +/// This is the facade-safe counterpart to [`fetch_account_traces_via_sink`]: it +/// uses the same [`trace_remote_http_client`] that trace submission uses, so it +/// obeys the same SSRF mitigations and TLS settings without coupling the caller +/// to a host-egress `ContributionHttpSink`. Use this from WebUI facades and any +/// non-agent surface. Use [`fetch_account_traces_via_sink`] from the agent +/// runtime where all egress must flow through `RuntimeHttpEgress`. +/// +/// Returns `Ok(vec![])` when the user is not enrolled or the server returns a +/// non-2xx status. Transport failures return `Err`. +pub async fn fetch_account_traces( + tenant_id: &str, + user_id: &str, + limit: Option, +) -> anyhow::Result> { + fetch_account_traces_direct( + ironclaw_common::paths::ironclaw_base_dir().as_path(), + tenant_id, + user_id, + limit, + ) + .await +} + +/// Dir-parameterised core for [`fetch_account_traces`] (direct/CLI path). +/// Accepts an explicit `base_dir` so tests can supply an isolated tempdir. +async fn fetch_account_traces_direct( + base_dir: &std::path::Path, + tenant_id: &str, + user_id: &str, + limit: Option, +) -> anyhow::Result> { + let resolution = match resolve_trace_credentials_at(base_dir, tenant_id, user_id)? { + Some(r) => r, + None => return Ok(vec![]), + }; + + let scope_dir = if resolution.subject.is_some() { + trace_contribution_dir_for_scope_at(base_dir, None) + } else { + trace_contribution_dir_for_scope_at(base_dir, Some(resolution.state_scope.as_str())) + }; + + let context = TraceUploadClaimContext::for_account(resolution.subject.clone()) + .with_scope_dir(scope_dir); + let provider = DefaultTraceUploadCredentialProvider; + let bearer = provider + .bearer_token(&resolution.policy, &context, false) + .await?; + let url = account_traces_url(&resolution.policy, limit)?; + let client = trace_remote_http_client() + .map_err(|e| anyhow::anyhow!("failed to build trace HTTP client: {e}"))?; + let response = client + .get(&url) + .bearer_auth(&bearer) + .send() + .await + .map_err(|e| anyhow::anyhow!("account traces request failed: {e}"))?; + if !response.status().is_success() { + return Ok(vec![]); + } + let body = response + .bytes() + .await + .map_err(|e| anyhow::anyhow!("failed to read account traces response body: {e}"))?; + let items: Vec = serde_json::from_slice(&body) + .context("account traces response was not a valid JSON array")?; + Ok(items) +} + /// Dir-parameterised core for [`fetch_account_traces_via_sink`]. /// Accepts an explicit `base_dir` so tests can supply an isolated tempdir. async fn fetch_account_traces_inner( From baab37550822f7c794102c2fc2290c67073e3172 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 16:28:17 -0700 Subject: [PATCH 18/46] feat(reborn): GET /api/webchat/v2/traces/account handler + contract test --- crates/ironclaw_webui_v2/src/descriptors.rs | 17 +++++++ crates/ironclaw_webui_v2/src/handlers.rs | 16 ++++++- crates/ironclaw_webui_v2/src/lib.rs | 7 +-- crates/ironclaw_webui_v2/src/router.rs | 8 +++- .../tests/webui_v2_descriptors_contract.rs | 22 +++++++++- .../tests/webui_v2_handlers_contract.rs | 44 +++++++++++++++++++ 6 files changed, 106 insertions(+), 8 deletions(-) diff --git a/crates/ironclaw_webui_v2/src/descriptors.rs b/crates/ironclaw_webui_v2/src/descriptors.rs index ca4a23c6c97..fde46f6326c 100644 --- a/crates/ironclaw_webui_v2/src/descriptors.rs +++ b/crates/ironclaw_webui_v2/src/descriptors.rs @@ -26,6 +26,7 @@ pub const WEBUI_V2_ROUTE_CANCEL_RUN: &str = "webui.v2.cancel_run"; pub const WEBUI_V2_ROUTE_RESOLVE_GATE: &str = "webui.v2.resolve_gate"; pub const WEBUI_V2_ROUTE_LIST_AUTOMATIONS: &str = "webui.v2.list_automations"; 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_GET_OUTBOUND_PREFERENCES: &str = "webui.v2.get_outbound_preferences"; pub const WEBUI_V2_ROUTE_SET_OUTBOUND_PREFERENCES: &str = "webui.v2.set_outbound_preferences"; @@ -85,6 +86,7 @@ pub const WEBUI_V2_PATTERN_RESOLVE_GATE: &str = "/api/webchat/v2/threads/{thread_id}/runs/{run_id}/gates/{gate_ref}/resolve"; pub const WEBUI_V2_PATTERN_LIST_AUTOMATIONS: &str = "/api/webchat/v2/automations"; 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_OUTBOUND_PREFERENCES: &str = "/api/webchat/v2/outbound/preferences"; @@ -148,6 +150,7 @@ pub fn webui_v2_routes() -> Vec { resolve_gate_descriptor(), list_automations_descriptor(), trace_credits_descriptor(), + trace_account_traces_descriptor(), authorize_trace_hold_descriptor(), get_outbound_preferences_descriptor(), set_outbound_preferences_descriptor(), @@ -462,6 +465,20 @@ fn trace_credits_descriptor() -> IngressRouteDescriptor { ) } +fn trace_account_traces_descriptor() -> IngressRouteDescriptor { + descriptor( + WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, + NetworkMethod::Get, + WEBUI_V2_PATTERN_TRACE_ACCOUNT_TRACES, + read_policy( + read_rate_limit(), + AuditTraceClass::UserAction, + AllowedEffectPath::ProductWorkflow, + StreamingMode::None, + ), + ) +} + fn authorize_trace_hold_descriptor() -> IngressRouteDescriptor { descriptor( WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, diff --git a/crates/ironclaw_webui_v2/src/handlers.rs b/crates/ironclaw_webui_v2/src/handlers.rs index 2acada83baf..4c8ebf88621 100644 --- a/crates/ironclaw_webui_v2/src/handlers.rs +++ b/crates/ironclaw_webui_v2/src/handlers.rs @@ -42,7 +42,8 @@ use ironclaw_product_workflow::{ RebornSetOutboundPreferencesRequest, RebornSetupExtensionResponse, RebornSkillActionResponse, RebornSkillContentResponse, RebornSkillListResponse, RebornSkillSearchResponse, RebornStreamEventsRequest, RebornSubmitTurnResponse, RebornTimelineRequest, - RebornTimelineResponse, RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse, + RebornAccountTracesResponse, RebornTimelineResponse, RebornTraceCreditsResponse, + RebornTraceHoldAuthorizeResponse, SetActiveLlmRequest, UpsertLlmProviderRequest, WebUiAttachmentCapabilities, WebUiAuthenticatedCaller, WebUiCancelRunRequest, WebUiCreateThreadRequest, WebUiInboundValidationCode, WebUiInboundValidationError, WebUiListAutomationsRequest, @@ -685,6 +686,19 @@ pub async fn trace_credits( Ok(Json(response)) } +/// `GET /api/webchat/v2/traces/account` +/// +/// Read-only list of the authenticated caller's submitted Trace Commons traces, +/// fetched per-user from the server. Scope is derived from the caller; no input +/// is accepted. Unenrolled callers receive the zero-state, not an error. +pub async fn trace_account_traces( + State(state): State, + Extension(caller): Extension, +) -> Result, WebUiV2HttpError> { + let response = state.services().trace_account_traces(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 740eb23a6d2..0bf4d5a82ac 100644 --- a/crates/ironclaw_webui_v2/src/lib.rs +++ b/crates/ironclaw_webui_v2/src/lib.rs @@ -80,8 +80,9 @@ pub use descriptors::{ WEBUI_V2_ROUTE_SETUP_EXTENSION, WEBUI_V2_ROUTE_START_CODEX_LOGIN, WEBUI_V2_ROUTE_START_NEARAI_LOGIN, 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_CREDITS, - WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_SKILL, + 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_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, is_webui_v2_operator_webui_config_route_id, webui_v2_routes, }; @@ -99,7 +100,7 @@ pub use handlers::{ run_operator_service_lifecycle, run_operator_setup, search_skills, send_message, set_active_llm, set_operator_config_key, set_outbound_preferences, setup_extension, start_codex_login, start_nearai_login, stream_events, stream_events_ws, test_llm_connection, - trace_credits, update_skill, upsert_llm_provider, + trace_account_traces, trace_credits, update_skill, upsert_llm_provider, }; #[cfg(feature = "webui-v2-beta")] pub use router::{ diff --git a/crates/ironclaw_webui_v2/src/router.rs b/crates/ironclaw_webui_v2/src/router.rs index 190b73d0ffd..13a1aae6ed1 100644 --- a/crates/ironclaw_webui_v2/src/router.rs +++ b/crates/ironclaw_webui_v2/src/router.rs @@ -34,8 +34,8 @@ use crate::descriptors::{ WEBUI_V2_PATTERN_SKILL_DETAIL, WEBUI_V2_PATTERN_START_CODEX_LOGIN, WEBUI_V2_PATTERN_START_NEARAI_LOGIN, 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_CREDITS, - WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, + WEBUI_V2_PATTERN_TEST_LLM_CONNECTION, 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; @@ -160,6 +160,10 @@ pub fn webui_v2_router_with_options(state: WebUiV2State, options: WebUiV2RouteOp get(handlers::list_automations), ) .route(WEBUI_V2_PATTERN_TRACE_CREDITS, get(handlers::trace_credits)) + .route( + WEBUI_V2_PATTERN_TRACE_ACCOUNT_TRACES, + get(handlers::trace_account_traces), + ) .route( WEBUI_V2_PATTERN_TRACE_HOLD_AUTHORIZE, post(handlers::authorize_trace_hold), 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 1d6f08e87c4..71ccbafa3a2 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs @@ -40,8 +40,9 @@ use ironclaw_webui_v2::{ WEBUI_V2_ROUTE_SETUP_EXTENSION, WEBUI_V2_ROUTE_START_CODEX_LOGIN, WEBUI_V2_ROUTE_START_NEARAI_LOGIN, 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_CREDITS, - WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_SKILL, + 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_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, }; @@ -295,6 +296,23 @@ fn expected_table() -> Vec { audit: AuditTraceClass::UserAction, effect_path: AllowedEffectPath::ProductWorkflow, }, + Expected { + route_id: WEBUI_V2_ROUTE_TRACE_ACCOUNT_TRACES, + method: NetworkMethod::Get, + pattern: "/api/webchat/v2/traces/account", + listener_class: ListenerClass::LocalGateway, + auth_schemes: &[IngressAuthScheme::BearerToken], + scope_source: IngressScopeSource::AuthenticatedCaller, + body_limit: BodyLimitPolicy::NoBody, + rate_limit_max: 120, + 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 cb523d10322..3dc872fc489 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -1781,6 +1781,50 @@ async fn trace_credits_returns_caller_scoped_unenrolled_zero_state() { ); } +#[tokio::test] +async fn trace_account_traces_returns_caller_scoped_unenrolled_zero_state() { + // The facade's default `trace_account_traces` body reads contributor-local + // Trace Commons state scoped by the authenticated caller's user id. + // A unique per-test user id guarantees a fresh scope so the + // unenrolled zero-state is deterministic on any machine. + let user_id = format!( + "webui-v2-account-traces-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + ); + let unique_caller = WebUiAuthenticatedCaller::new( + TenantId::new("tenant-alpha").expect("tenant"), + UserId::new(user_id.as_str()).expect("user"), + None, + None, + ); + let router = webui_v2_router(WebUiV2State::new( + Arc::new(StubServices::default()), + DEFAULT_SSE_MAX_CONCURRENT_PER_CALLER, + )) + .layer(axum::Extension(unique_caller)) + .layer(axum::Extension(WebUiV2Capabilities::default())); + + let response = router + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/webchat/v2/traces/account") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::OK); + let body = read_json(response).await; + assert_eq!(body["enrolled"], false); + assert_eq!(body["traces"].as_array().expect("traces array").len(), 0); +} + #[tokio::test] async fn list_automations_rejects_invalid_limit_query_with_400() { let services = Arc::new(StubServices::default()); From 4bc74a6edb9fba5fb94d8ce9caeecb3dac7823ce Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 16:32:59 -0700 Subject: [PATCH 19/46] feat(reborn-ui): render submitted Trace Commons traces in settings Co-Authored-By: Claude Sonnet 4.6 --- .../static/js/i18n/en.js | 5 ++ .../settings/components/trace-commons-tab.js | 48 +++++++++++++++++++ .../pages/settings/hooks/useAccountTraces.js | 18 +++++++ .../js/pages/settings/lib/settings-api.js | 5 ++ 4 files changed, 76 insertions(+) create mode 100644 crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useAccountTraces.js diff --git a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js index 160e45e4712..f1d22c943db 100644 --- a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js +++ b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js @@ -226,6 +226,11 @@ 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.submittedTracesTitle": "Submitted traces", + "traceCommons.traceStatus": "Status", + "traceCommons.tracePendingCredit": "Pending credit", + "traceCommons.traceFinalCredit": "Final credit", + "traceCommons.traceReceivedAt": "Received", // Settings — toolbar "settings.back": "Back", diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/trace-commons-tab.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/trace-commons-tab.js index f5eb6059574..40191e2ff09 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/trace-commons-tab.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/components/trace-commons-tab.js @@ -2,6 +2,7 @@ import { html } from "../../../lib/html.js"; import { Card } from "../../../design-system/card.js"; import { useT } from "../../../lib/i18n.js"; import { useTraceCredits } from "../hooks/useTraceCredits.js"; +import { useAccountTraces } from "../hooks/useAccountTraces.js"; import { matchesSearch } from "../lib/settings-search.js"; import { SettingsSearchEmpty } from "./settings-search-empty.js"; @@ -38,6 +39,7 @@ function StatRow({ label, value, description }) { export function TraceCommonsTab({ searchQuery = "" }) { const t = useT(); const { credits, query, authorize } = useTraceCredits(); + const { traces, enrolled: tracesEnrolled } = useAccountTraces(); if ( !matchesSearch(searchQuery, [ @@ -178,6 +180,52 @@ export function TraceCommonsTab({ searchQuery = "" }) { `} + ${tracesEnrolled && traces.length > 0 && + html` +
+

+ ${t("traceCommons.submittedTracesTitle")} +

+
    + ${traces.map( + (trace) => html` +
  • +
    +
    + ${trace.submission_id} +
    +
    + ${trace.status} +
    +
    +
    + + ${t("traceCommons.tracePendingCredit")}:${" "} + + ${formatCredit(trace.pending_credit)} + + + + ${t("traceCommons.traceFinalCredit")}:${" "} + + ${formatCredit(trace.final_credit)} + + + + ${formatTimestamp(trace.received_at, t)} + +
    +
  • + ` + )} +
+
+ `} `; } diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useAccountTraces.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useAccountTraces.js new file mode 100644 index 00000000000..875b5c3fcc4 --- /dev/null +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/hooks/useAccountTraces.js @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchAccountTraces } from "../lib/settings-api.js"; + +export function useAccountTraces() { + const query = useQuery({ + queryKey: ["account-traces"], + queryFn: fetchAccountTraces, + // Trace submissions change slowly. Mirror the cadence used by + // useTraceCredits: focus refetch for immediacy, infrequent poll to + // stay live without hammering the server. staleTime dedupes redundant + // focus refetches when the data is fresh. + refetchInterval: 300_000, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + staleTime: 60_000, + }); + return { traces: query.data?.traces || [], enrolled: !!query.data?.enrolled, query }; +} diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/settings/lib/settings-api.js b/crates/ironclaw_webui_v2_static/static/js/pages/settings/lib/settings-api.js index f690fd2c651..43507b920fc 100644 --- a/crates/ironclaw_webui_v2_static/static/js/pages/settings/lib/settings-api.js +++ b/crates/ironclaw_webui_v2_static/static/js/pages/settings/lib/settings-api.js @@ -123,6 +123,11 @@ export function removeSkill(name) { export function fetchTraceCredits() { return apiFetch("/api/webchat/v2/traces/credit"); } +// Submitted Trace Commons traces for the authenticated caller (read-only, +// server-scoped). Mirrors fetchTraceCredits. +export function fetchAccountTraces() { + return apiFetch("/api/webchat/v2/traces/account"); +} // 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) { From 8dc73152df4122baca1206edfbeea813fd179b42 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 16:47:42 -0700 Subject: [PATCH 20/46] chore(traces): document flush-gate limitation, hermetic account-traces contract test, annotate sink scaffold Co-Authored-By: Claude Sonnet 4.6 --- .../ironclaw_reborn_traces/src/contribution.rs | 12 ++++++++++++ .../tests/webui_v2_handlers_contract.rs | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 3b54ed085a0..314681132a7 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -6168,6 +6168,11 @@ pub struct AccountTraceItem { pub received_at: Option, } +/// Agent-path (host-egress sink) counterpart to the direct `fetch_account_traces`. +/// Not yet wired to a first-party capability — retained as the sink-based entry +/// for a future `trace_commons.account_traces` agent capability, mirroring +/// `mint_account_login_link_via_sink`. Covered by unit tests. +/// /// Fetch the list of submitted traces for the given `(tenant_id, user_id)` via /// the caller-supplied `sink` (host egress on the agent path). /// @@ -6605,6 +6610,13 @@ async fn flush_trace_contribution_queue_for_scope_with_credential_provider( return Err(error); } }; + // NOTE: This gates on the PER-SCOPE (personal-invite) policy only. An + // instance-enrolled user (enrolled at scope None via admin instance + // enrollment) has no enabled per-scope policy and therefore aborts here — + // instance-enrolled contribution is NOT yet wired into the autonomous flush + // path. A resolver-aware flush gate (use resolve_trace_credentials, fall back + // to the instance policy) is the planned follow-up. Until then, `subject_for_scope` + // below is effectively inert in this path. Tracking: . if !policy.enabled { let error = anyhow::anyhow!("trace contribution opt-in is disabled"); record_trace_queue_flush_failure_for_scope_unlocked(scope, &error, flush_started_at)?; 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 3dc872fc489..b3d742d8341 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -51,8 +51,9 @@ use ironclaw_product_workflow::{ RebornServicesApi, RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind, RebornSetOutboundPreferencesRequest, RebornSetupExtensionResponse, RebornSkillActionResponse, RebornSkillContentResponse, RebornSkillListResponse, RebornSkillSearchResponse, - RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, - RebornTimelineRequest, RebornTimelineResponse, SetActiveLlmRequest, UpsertLlmProviderRequest, + RebornAccountTracesResponse, RebornStreamEventsRequest, RebornStreamEventsResponse, + RebornSubmitTurnResponse, RebornTimelineRequest, RebornTimelineResponse, SetActiveLlmRequest, + UpsertLlmProviderRequest, WebUiAuthenticatedCaller, WebUiCancelRunRequest, WebUiCreateThreadRequest, WebUiListAutomationsRequest, WebUiListThreadsRequest, WebUiResolveGateRequest, WebUiSendMessageRequest, WebUiSetupExtensionRequest, rejecting_reborn_services_error, @@ -1029,6 +1030,19 @@ impl RebornServicesApi for StubServices { message: String::new(), }) } + + async fn trace_account_traces( + &self, + _caller: WebUiAuthenticatedCaller, + ) -> Result { + // Hermetic zero-state stub — no filesystem or network access. + // Mirrors the unenrolled branch of the real `account_traces_for_user` + // so the contract test for `GET /traces/account` is fully self-contained. + Ok(RebornAccountTracesResponse { + enrolled: false, + traces: vec![], + }) + } } fn operator_command_response(area: RebornOperatorArea) -> RebornOperatorCommandPlaneResponse { From 65ee74d7da29ef878f4324664502b68b68b93769 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 16:49:09 -0700 Subject: [PATCH 21/46] style(traces): cargo fmt across Trace Commons slice changes Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ironclaw_product_workflow/src/lib.rs | 13 +-- .../src/reborn_services.rs | 9 +-- .../src/contribution.rs | 80 +++++++++---------- .../src/onboarding/tests.rs | 3 +- crates/ironclaw_webui_v2/src/handlers.rs | 43 +++++----- crates/ironclaw_webui_v2/src/lib.rs | 3 +- .../tests/webui_v2_descriptors_contract.rs | 3 +- .../tests/webui_v2_handlers_contract.rs | 24 +++--- 8 files changed, 82 insertions(+), 96 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index 2135e2fa048..2f207b783ef 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -151,10 +151,11 @@ pub use reborn_services::{ NearAiWalletLoginResult, OperatorLogsService, OperatorServiceLifecycleService, OperatorStatusService, OutboundPreferencesProductFacade, ProductAgentBoundCaller, ProjectFilesystemReader, ProjectFsEntry, ProjectFsEntryKind, ProjectFsError, ProjectFsFile, - ProjectFsStat, RebornAttachmentBytes, RebornAttachmentRequest, RebornAutomationInfo, - RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationRunStatus, - RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, - RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, + ProjectFsStat, RebornAccountTrace, RebornAccountTracesResponse, RebornAttachmentBytes, + RebornAttachmentRequest, RebornAutomationInfo, RebornAutomationRecentRunInfo, + RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource, + RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, + RebornChannelConnectStrategy, RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, RebornDeleteThreadResponse, RebornExtensionActionResponse, RebornExtensionCredentialSetup, RebornExtensionInfo, RebornExtensionListResponse, RebornExtensionOnboardingPayload, @@ -186,8 +187,8 @@ pub use reborn_services::{ RebornSkillContentResponse, RebornSkillInfo, RebornSkillListResponse, RebornSkillSearchResponse, RebornSkillSourceKind, RebornSkillTrustLevel, RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, - RebornAccountTrace, RebornAccountTracesResponse, RebornTimelineRequest, RebornTimelineResponse, - RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse, SetActiveLlmRequest, SkillsProductFacade, + RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, + RebornTraceHoldAuthorizeResponse, SetActiveLlmRequest, SkillsProductFacade, StaticConnectableChannelsProductFacade, StaticOperatorStatusService, TriggerRunThreadScope, UnsupportedAutomationProductFacade, UnsupportedOperatorLogsService, UnsupportedOperatorServiceLifecycleService, UnsupportedOperatorStatusService, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 1f75712dd1d..29eb547aa81 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -1088,12 +1088,9 @@ pub trait RebornServicesApi: Send + Sync { caller: WebUiAuthenticatedCaller, ) -> Result { let actor = caller.actor(); - trace_credits::account_traces_for_user( - caller.tenant_id.as_str(), - actor.user_id.as_str(), - ) - .await - .map_err(RebornServicesError::internal_from) + trace_credits::account_traces_for_user(caller.tenant_id.as_str(), actor.user_id.as_str()) + .await + .map_err(RebornServicesError::internal_from) } /// Authorize the caller's held manual-review trace for submission diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 314681132a7..88c5233e242 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -6017,12 +6017,9 @@ pub struct AccountLoginLink { /// endpoints (`/v1/account/login-links`, `/v1/account/traces`, …) are built on /// top of this shared origin so the derivation is not duplicated. fn account_api_base_url(policy: &StandingTraceContributionPolicy) -> anyhow::Result { - let issuer_url = policy - .upload_token_issuer_url - .as_deref() - .ok_or_else(|| { - anyhow::anyhow!("Trace Commons upload token issuer URL is not configured") - })?; + let issuer_url = policy.upload_token_issuer_url.as_deref().ok_or_else(|| { + anyhow::anyhow!("Trace Commons upload token issuer URL is not configured") + })?; let base = issuer_url .trim_end_matches('/') .strip_suffix("/v1/trace-upload-claim") @@ -6039,7 +6036,10 @@ fn account_api_base_url(policy: &StandingTraceContributionPolicy) -> anyhow::Res /// the path differs: strip `/v1/trace-upload-claim`, append /// `/v1/account/login-links`. fn account_login_links_url(policy: &StandingTraceContributionPolicy) -> anyhow::Result { - Ok(format!("{}/v1/account/login-links", account_api_base_url(policy)?)) + Ok(format!( + "{}/v1/account/login-links", + account_api_base_url(policy)? + )) } /// Derive the account-traces URL from the configured upload-claim issuer URL. @@ -6102,8 +6102,8 @@ async fn mint_account_login_link_inner( trace_contribution_dir_for_scope_at(base_dir, Some(resolution.state_scope.as_str())) }; - let context = TraceUploadClaimContext::for_account(resolution.subject.clone()) - .with_scope_dir(scope_dir); + let context = + TraceUploadClaimContext::for_account(resolution.subject.clone()).with_scope_dir(scope_dir); let provider = DefaultTraceUploadCredentialProvider; let bearer = provider .bearer_token(&resolution.policy, &context, false) @@ -6119,8 +6119,7 @@ async fn mint_account_login_link_inner( url, bearer_token: Some(bearer), json_body: Some( - serde_json::to_vec(&body) - .context("failed to serialize login-link request body")?, + serde_json::to_vec(&body).context("failed to serialize login-link request body")?, ), response_body_limit: TRACE_UPLOAD_CLAIM_MAX_RESPONSE_BYTES as u64, timeout_ms: 10_000, @@ -6132,8 +6131,8 @@ async fn mint_account_login_link_inner( "login-link request returned HTTP {}", response.status ); - let parsed: serde_json::Value = serde_json::from_slice(&response.body) - .context("login-link response was not valid JSON")?; + let parsed: serde_json::Value = + serde_json::from_slice(&response.body).context("login-link response was not valid JSON")?; let account_id = parsed["account_id"] .as_str() .ok_or_else(|| anyhow::anyhow!("login-link response missing account_id field"))? @@ -6245,8 +6244,8 @@ async fn fetch_account_traces_direct( trace_contribution_dir_for_scope_at(base_dir, Some(resolution.state_scope.as_str())) }; - let context = TraceUploadClaimContext::for_account(resolution.subject.clone()) - .with_scope_dir(scope_dir); + let context = + TraceUploadClaimContext::for_account(resolution.subject.clone()).with_scope_dir(scope_dir); let provider = DefaultTraceUploadCredentialProvider; let bearer = provider .bearer_token(&resolution.policy, &context, false) @@ -6292,8 +6291,8 @@ async fn fetch_account_traces_inner( trace_contribution_dir_for_scope_at(base_dir, Some(resolution.state_scope.as_str())) }; - let context = TraceUploadClaimContext::for_account(resolution.subject.clone()) - .with_scope_dir(scope_dir); + let context = + TraceUploadClaimContext::for_account(resolution.subject.clone()).with_scope_dir(scope_dir); let provider = DefaultTraceUploadCredentialProvider; let bearer = provider .bearer_token(&resolution.policy, &context, false) @@ -6318,7 +6317,6 @@ async fn fetch_account_traces_inner( Ok(items) } - #[cfg(test)] tokio::task_local! { /// Test-only, task-scoped override for the remote-request timeout. @@ -13132,7 +13130,9 @@ mod tests { ); 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; }); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); let scope_dir = tempfile::tempdir().unwrap(); crate::onboarding::DeviceKeypair::load_or_generate_pending(scope_dir.path(), "h") @@ -13145,7 +13145,7 @@ mod tests { auth_mode: TraceUploadAuthMode::DeviceKey, upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from([ - "127.0.0.1".to_string(), + "127.0.0.1".to_string() ]), upload_token_tenant_id: Some("tenant-dev".to_string()), upload_token_audience: Some("trace-commons".to_string()), @@ -15176,7 +15176,8 @@ mod tests { use std::sync::{Arc, Mutex}; // A syntactically valid JWT that passes validate_trace_upload_claim_response. - let claim_jwt = test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "test-key-1"})); + let claim_jwt = + test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "test-key-1"})); let claim_jwt_for_mock = claim_jwt.clone(); // ── mock server ────────────────────────────────────────────────────── @@ -15216,9 +15217,7 @@ mod tests { }), ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .unwrap(); + 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; @@ -15234,7 +15233,7 @@ mod tests { auth_mode: TraceUploadAuthMode::DeviceKey, upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from([ - "127.0.0.1".to_string(), + "127.0.0.1".to_string() ]), upload_token_tenant_id: Some("tenant-dev".to_string()), upload_token_audience: Some("trace-commons-ingest".to_string()), @@ -15259,10 +15258,7 @@ mod tests { // ── assertions ─────────────────────────────────────────────────────── assert_eq!(link.url, "/account/login?code=abc"); - assert_eq!( - link.account_id, - "11111111-1111-1111-1111-111111111111" - ); + assert_eq!(link.account_id, "11111111-1111-1111-1111-111111111111"); let bodies = captured.lock().unwrap(); assert_eq!(bodies.len(), 1, "exactly one POST to login-links"); @@ -15328,8 +15324,7 @@ mod tests { }; let url_no_limit = account_traces_url(&policy, None).expect("no-limit must succeed"); assert_eq!(url_no_limit, "https://api.example.com/v1/account/traces"); - let url_with_limit = - account_traces_url(&policy, Some(50)).expect("limit=50 must succeed"); + let url_with_limit = account_traces_url(&policy, Some(50)).expect("limit=50 must succeed"); assert_eq!( url_with_limit, "https://api.example.com/v1/account/traces?limit=50" @@ -15339,7 +15334,8 @@ mod tests { #[tokio::test] async fn fetch_account_traces_returns_user_submissions() { // A syntactically valid JWT that passes validate_trace_upload_claim_response. - let claim_jwt = test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "test-key-1"})); + let claim_jwt = + test_jwt_with_header(serde_json::json!({"alg": "EdDSA", "kid": "test-key-1"})); let claim_jwt_for_mock = claim_jwt.clone(); // ── mock server ────────────────────────────────────────────────────── @@ -15375,9 +15371,7 @@ mod tests { }), ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .unwrap(); + 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; @@ -15393,7 +15387,7 @@ mod tests { auth_mode: TraceUploadAuthMode::DeviceKey, upload_token_issuer_url: Some(format!("http://{addr}/v1/trace-upload-claim")), upload_token_issuer_allowed_hosts: std::collections::BTreeSet::from([ - "127.0.0.1".to_string(), + "127.0.0.1".to_string() ]), upload_token_tenant_id: Some("tenant-dev".to_string()), upload_token_audience: Some("trace-commons-ingest".to_string()), @@ -15412,10 +15406,9 @@ mod tests { // ── call under test ────────────────────────────────────────────────── let sink = ReqwestContributionSink; - let items = - fetch_account_traces_inner(base.path(), "tenant-dev", "alice", Some(50), &sink) - .await - .unwrap(); + let items = fetch_account_traces_inner(base.path(), "tenant-dev", "alice", Some(50), &sink) + .await + .unwrap(); // ── assertions ─────────────────────────────────────────────────────── assert_eq!(items.len(), 1, "expected exactly one trace item"); @@ -15441,10 +15434,9 @@ mod tests { let base = tempfile::tempdir().unwrap(); // No policy written — resolver returns None → lenient Ok(vec![]). let sink = ReqwestContributionSink; - let items = - fetch_account_traces_inner(base.path(), "tenant-dev", "alice", None, &sink) - .await - .unwrap(); + let items = fetch_account_traces_inner(base.path(), "tenant-dev", "alice", None, &sink) + .await + .unwrap(); assert!(items.is_empty(), "unenrolled user must return empty list"); } } diff --git a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs index 0690be99ee7..47f4fbcbed4 100644 --- a/crates/ironclaw_reborn_traces/src/onboarding/tests.rs +++ b/crates/ironclaw_reborn_traces/src/onboarding/tests.rs @@ -867,8 +867,7 @@ async fn instance_onboard_writes_instance_level_policy() { assert_eq!(outcome.tenant_id, "tenant-fake"); // The policy must land at the scope-None location (no users/ segment). - let raw = std::fs::read_to_string(instance_dir.join("policy.json")) - .expect("policy written"); + let raw = std::fs::read_to_string(instance_dir.join("policy.json")).expect("policy written"); let policy: StandingTraceContributionPolicy = serde_json::from_str(&raw).expect("policy parses"); assert!(policy.enabled); diff --git a/crates/ironclaw_webui_v2/src/handlers.rs b/crates/ironclaw_webui_v2/src/handlers.rs index 4c8ebf88621..a9ada6b6436 100644 --- a/crates/ironclaw_webui_v2/src/handlers.rs +++ b/crates/ironclaw_webui_v2/src/handlers.rs @@ -27,28 +27,27 @@ use ironclaw_product_workflow::{ CodexLoginStart, LifecyclePackageKind, LifecyclePackageRef, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductWorkflowError, ProjectionCursor, - RebornAttachmentRequest, RebornCancelRunResponse, RebornConnectableChannelListResponse, - RebornCreateThreadResponse, RebornDeleteThreadRequest, RebornDeleteThreadResponse, - RebornExtensionActionResponse, RebornExtensionListResponse, RebornExtensionRegistryResponse, - RebornListAutomationsResponse, RebornListThreadsResponse, RebornOperatorCommandPlaneResponse, - RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, - RebornOperatorConfigSetRequest, RebornOperatorConfigValidateRequest, - RebornOperatorConfigValidateResponse, RebornOperatorLogsQuery, - RebornOperatorServiceLifecycleRequest, RebornOperatorSetupRequest, RebornOperatorSetupResponse, - RebornOutboundDeliveryTargetListResponse, RebornOutboundPreferencesResponse, - RebornProjectFsListRequest, RebornProjectFsListResponse, RebornProjectFsReadRequest, - RebornProjectFsStatRequest, RebornProjectFsStatResponse, RebornResolveGateResponse, - RebornServicesApi, RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind, - RebornSetOutboundPreferencesRequest, RebornSetupExtensionResponse, RebornSkillActionResponse, - RebornSkillContentResponse, RebornSkillListResponse, RebornSkillSearchResponse, - RebornStreamEventsRequest, RebornSubmitTurnResponse, RebornTimelineRequest, - RebornAccountTracesResponse, RebornTimelineResponse, RebornTraceCreditsResponse, - RebornTraceHoldAuthorizeResponse, - SetActiveLlmRequest, UpsertLlmProviderRequest, WebUiAttachmentCapabilities, - WebUiAuthenticatedCaller, WebUiCancelRunRequest, WebUiCreateThreadRequest, - WebUiInboundValidationCode, WebUiInboundValidationError, WebUiListAutomationsRequest, - WebUiListThreadsRequest, WebUiResolveGateRequest, WebUiSendMessageRequest, - WebUiSetupExtensionRequest, webui_attachment_capabilities, + RebornAccountTracesResponse, RebornAttachmentRequest, RebornCancelRunResponse, + RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, + RebornDeleteThreadResponse, RebornExtensionActionResponse, RebornExtensionListResponse, + RebornExtensionRegistryResponse, RebornListAutomationsResponse, RebornListThreadsResponse, + RebornOperatorCommandPlaneResponse, RebornOperatorConfigGetResponse, + RebornOperatorConfigListResponse, RebornOperatorConfigSetRequest, + RebornOperatorConfigValidateRequest, RebornOperatorConfigValidateResponse, + RebornOperatorLogsQuery, RebornOperatorServiceLifecycleRequest, RebornOperatorSetupRequest, + RebornOperatorSetupResponse, RebornOutboundDeliveryTargetListResponse, + RebornOutboundPreferencesResponse, RebornProjectFsListRequest, RebornProjectFsListResponse, + RebornProjectFsReadRequest, RebornProjectFsStatRequest, RebornProjectFsStatResponse, + RebornResolveGateResponse, RebornServicesApi, RebornServicesError, RebornServicesErrorCode, + RebornServicesErrorKind, RebornSetOutboundPreferencesRequest, RebornSetupExtensionResponse, + RebornSkillActionResponse, RebornSkillContentResponse, RebornSkillListResponse, + RebornSkillSearchResponse, RebornStreamEventsRequest, RebornSubmitTurnResponse, + RebornTimelineRequest, RebornTimelineResponse, RebornTraceCreditsResponse, + RebornTraceHoldAuthorizeResponse, SetActiveLlmRequest, UpsertLlmProviderRequest, + WebUiAttachmentCapabilities, WebUiAuthenticatedCaller, WebUiCancelRunRequest, + WebUiCreateThreadRequest, WebUiInboundValidationCode, WebUiInboundValidationError, + WebUiListAutomationsRequest, WebUiListThreadsRequest, WebUiResolveGateRequest, + WebUiSendMessageRequest, WebUiSetupExtensionRequest, webui_attachment_capabilities, }; use serde::{Deserialize, Serialize}; diff --git a/crates/ironclaw_webui_v2/src/lib.rs b/crates/ironclaw_webui_v2/src/lib.rs index 0bf4d5a82ac..351ce40caea 100644 --- a/crates/ironclaw_webui_v2/src/lib.rs +++ b/crates/ironclaw_webui_v2/src/lib.rs @@ -81,8 +81,7 @@ pub use descriptors::{ WEBUI_V2_ROUTE_START_NEARAI_LOGIN, 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_SKILL, + WEBUI_V2_ROUTE_TRACE_CREDITS, WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, is_webui_v2_operator_webui_config_route_id, webui_v2_routes, }; 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 71ccbafa3a2..50573f035da 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs @@ -41,8 +41,7 @@ use ironclaw_webui_v2::{ WEBUI_V2_ROUTE_START_NEARAI_LOGIN, 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_SKILL, + WEBUI_V2_ROUTE_TRACE_CREDITS, WEBUI_V2_ROUTE_TRACE_HOLD_AUTHORIZE, WEBUI_V2_ROUTE_UPDATE_SKILL, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, }; 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 b3d742d8341..0e0fa627e3b 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -28,15 +28,16 @@ use ironclaw_product_adapters::{ }; use ironclaw_product_workflow::{ LifecyclePackageRef, LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, - LlmProbeRequest, LlmProbeResult, LlmProviderView, RebornAttachmentBytes, - RebornAttachmentRequest, RebornAutomationInfo, RebornAutomationRecentRunInfo, - RebornAutomationRecentRunStatus, RebornAutomationSource, RebornAutomationState, - RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, - RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, - RebornDeleteThreadRequest, RebornDeleteThreadResponse, RebornExtensionActionResponse, - RebornExtensionListResponse, RebornExtensionRegistryResponse, RebornGetRunStateRequest, - RebornGetRunStateResponse, RebornListAutomationsResponse, RebornListThreadsResponse, - RebornOperatorArea, RebornOperatorCommandPlaneResponse, RebornOperatorConfigDiagnostic, + LlmProbeRequest, LlmProbeResult, LlmProviderView, RebornAccountTracesResponse, + RebornAttachmentBytes, RebornAttachmentRequest, RebornAutomationInfo, + RebornAutomationRecentRunInfo, RebornAutomationRecentRunStatus, RebornAutomationSource, + RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, + RebornChannelConnectStrategy, RebornConnectableChannelInfo, + RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornDeleteThreadRequest, + RebornDeleteThreadResponse, RebornExtensionActionResponse, RebornExtensionListResponse, + RebornExtensionRegistryResponse, RebornGetRunStateRequest, RebornGetRunStateResponse, + RebornListAutomationsResponse, RebornListThreadsResponse, RebornOperatorArea, + RebornOperatorCommandPlaneResponse, RebornOperatorConfigDiagnostic, RebornOperatorConfigDiagnosticSeverity, RebornOperatorConfigEntry, RebornOperatorConfigGetResponse, RebornOperatorConfigListResponse, RebornOperatorConfigSetRequest, RebornOperatorConfigValidateRequest, @@ -51,9 +52,8 @@ use ironclaw_product_workflow::{ RebornServicesApi, RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind, RebornSetOutboundPreferencesRequest, RebornSetupExtensionResponse, RebornSkillActionResponse, RebornSkillContentResponse, RebornSkillListResponse, RebornSkillSearchResponse, - RebornAccountTracesResponse, RebornStreamEventsRequest, RebornStreamEventsResponse, - RebornSubmitTurnResponse, RebornTimelineRequest, RebornTimelineResponse, SetActiveLlmRequest, - UpsertLlmProviderRequest, + RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, + RebornTimelineRequest, RebornTimelineResponse, SetActiveLlmRequest, UpsertLlmProviderRequest, WebUiAuthenticatedCaller, WebUiCancelRunRequest, WebUiCreateThreadRequest, WebUiListAutomationsRequest, WebUiListThreadsRequest, WebUiResolveGateRequest, WebUiSendMessageRequest, WebUiSetupExtensionRequest, rejecting_reborn_services_error, From a4e0f1a5ad5c592c304c36ad86ad48a467291fd2 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 18:37:08 -0700 Subject: [PATCH 22/46] feat(traces): resolver-aware flush gate (instance-enrolled users can contribute) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autonomous trace-flush gate read only the per-scope (personal-invite) policy and aborted when it was disabled, so instance-enrolled users (whose enrollment lives at scope None) could never contribute traces — and the per-user scope_dir would also fail to load the instance device key. Introduce a single EffectiveFlushTarget resolver (resolve_effective_flush_target, mirroring resolve_trace_credentials but keyed on the already-composed scope string) that returns the policy, device-key dir, and per-user subject in one policy-read/path pass. The flush gate now proceeds for instance-only enrollment, loads the device key from the instance (None) dir, and attributes uploads via the per-user pseudonymous subject. The redundant subject_for_scope helper (which re-read the same policies with silent .ok() error swallowing) is removed and its logic folded into the new helper with proper error propagation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/contribution.rs | 194 +++++++++++++----- 1 file changed, 142 insertions(+), 52 deletions(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 88c5233e242..28f9c129c56 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -4156,6 +4156,62 @@ pub fn resolve_trace_credentials( ) } +/// The effective enrollment a scope contributes under during an autonomous +/// flush: the policy to gate/submit with, the directory that holds the device +/// key, and the per-user subject (if any) to attribute the upload to. +/// +/// This consolidates the flush gate and per-user subject derivation into a +/// single policy-read/path-resolution pass so the two cannot drift — earlier +/// the gate and the subject derivation re-read the same policies independently. +struct EffectiveFlushTarget { + policy: StandingTraceContributionPolicy, + device_key_dir: PathBuf, + subject: Option, +} + +/// Inner implementation that reads policies relative to an explicit base dir. +/// Used by `resolve_effective_flush_target` (real base) and by tests (tempdir). +fn resolve_effective_flush_target_at( + base: &std::path::Path, + scope: Option<&str>, +) -> anyhow::Result> { + // Personal-invite enrollment: the per-scope policy is enabled and its device + // key is already 1:1 with the user, so no explicit subject is needed. + let personal = read_trace_policy_for_scope_at(base, scope) + .map_err(|e| anyhow::anyhow!("failed to read personal trace policy: {e}"))?; + if personal.enabled { + return Ok(Some(EffectiveFlushTarget { + policy: personal, + device_key_dir: trace_contribution_dir_for_scope_at(base, scope), + subject: None, + })); + } + + // Instance enrollment: no enabled per-scope policy, but the admin-provisioned + // instance policy (scope None) is enabled. The device key lives at the shared + // instance dir and uploads are attributed via a per-user pseudonymous subject. + let instance = read_trace_policy_for_scope_at(base, None) + .map_err(|e| anyhow::anyhow!("failed to read instance trace policy: {e}"))?; + if instance.enabled { + return Ok(Some(EffectiveFlushTarget { + policy: instance, + device_key_dir: trace_contribution_dir_for_scope_at(base, None), + subject: scope.map(local_pseudonymous_contributor_id), + })); + } + + Ok(None) +} + +/// Resolve the enrollment a scope contributes under for the autonomous flush +/// path. See [`resolve_effective_flush_target_at`]. Returns `Ok(None)` when the +/// scope is enrolled in neither a personal-invite nor an instance enrollment. +fn resolve_effective_flush_target( + scope: Option<&str>, +) -> anyhow::Result> { + resolve_effective_flush_target_at(&ironclaw_common::paths::ironclaw_base_dir(), scope) +} + pub fn mark_trace_credit_notice_due_for_scope( scope: Option<&str>, ) -> anyhow::Result> { @@ -6388,38 +6444,6 @@ fn trace_remote_http_client() -> Result) -> Option { - let s = scope?; - // Personal-invite enrollment: scope-level policy is enabled, device key already 1:1. - let personal_enabled = read_trace_policy_for_scope(Some(s)) - .ok() - .map(|p| p.enabled) - .unwrap_or(false); - if personal_enabled { - return None; - } - // Instance enrollment: no scope-level policy but the global instance policy is enabled. - let instance_enabled = read_trace_policy_for_scope(None) - .ok() - .map(|p| p.enabled) - .unwrap_or(false); - if instance_enabled { - return Some(local_pseudonymous_contributor_id(s)); - } - None -} - pub async fn submit_trace_envelope_to_endpoint( envelope: &TraceContributionEnvelope, endpoint: &str, @@ -6597,40 +6621,36 @@ async fn flush_trace_contribution_queue_for_scope_with_credential_provider( let flush_started_at = Utc::now(); record_trace_queue_flush_attempt_for_scope_unlocked(scope, flush_started_at)?; - // Compute the scope's base directory once; passed into contexts so that - // DeviceKey auth mode can locate the per-tenant keypair. - let scope_dir = trace_contribution_dir_for_scope(scope); - - let policy = match read_trace_policy_for_scope(scope) { - Ok(policy) => policy, + // Resolve which enrollment this scope contributes under in a single + // policy-read/path pass. A personal-invite enrollment uses the per-scope + // policy + per-scope device-key dir + no subject; an instance enrollment + // (no enabled per-scope policy, but the admin-provisioned instance policy at + // scope None is enabled) uses the instance policy + instance device-key dir + // + a per-user pseudonymous subject. `Ok(None)` means unenrolled and the + // flush aborts, exactly as before. + let target = match resolve_effective_flush_target(scope) { + Ok(target) => target, Err(error) => { record_trace_queue_flush_failure_for_scope_unlocked(scope, &error, flush_started_at)?; return Err(error); } }; - // NOTE: This gates on the PER-SCOPE (personal-invite) policy only. An - // instance-enrolled user (enrolled at scope None via admin instance - // enrollment) has no enabled per-scope policy and therefore aborts here — - // instance-enrolled contribution is NOT yet wired into the autonomous flush - // path. A resolver-aware flush gate (use resolve_trace_credentials, fall back - // to the instance policy) is the planned follow-up. Until then, `subject_for_scope` - // below is effectively inert in this path. Tracking: . - if !policy.enabled { + let Some(EffectiveFlushTarget { + policy, + device_key_dir: scope_dir, + subject, + }) = target + else { let error = anyhow::anyhow!("trace contribution opt-in is disabled"); record_trace_queue_flush_failure_for_scope_unlocked(scope, &error, flush_started_at)?; return Err(error); - } + }; let Some(endpoint) = policy.ingestion_endpoint.as_deref() else { let error = anyhow::anyhow!("trace contribution endpoint is not configured"); record_trace_queue_flush_failure_for_scope_unlocked(scope, &error, flush_started_at)?; return Err(error); }; - // Derive the per-user pseudonymous subject so instance-enrolled users are attributed - // individually under the shared tenant device key. Personal-invite enrollments and - // paths with no scope resolve to `None`, preserving today's behavior. - let subject = subject_for_scope(scope); - let compaction = match compact_trace_queue_for_scope_unlocked(scope) { Ok(report) => report, Err(error) => { @@ -15074,6 +15094,76 @@ mod tests { ); } + // --- resolve_effective_flush_target tests --- + // Same isolation contract as the resolver tests: each uses its own tempdir + // passed to the private `_at` core, so they never touch the global + // IRONCLAW_BASE_DIR. These prove the autonomous flush gate is resolver-aware: + // an instance-only enrollment resolves to a contributing target (so the gate + // no longer aborts) carrying the per-user pseudonymous subject and the + // INSTANCE device-key dir. + + #[test] + fn effective_flush_target_personal_enabled_uses_scope_dir_and_no_subject() { + let dir = tempfile::tempdir().unwrap(); + let scope = trace_scope_key("tenant-a", "alice"); + let personal = StandingTraceContributionPolicy { + enabled: true, + ..Default::default() + }; + write_policy_at(dir.path(), Some(scope.as_str()), &personal); + + let target = resolve_effective_flush_target_at(dir.path(), Some(scope.as_str())) + .unwrap() + .expect("personal-enabled scope is a contributing target"); + assert!(target.policy.enabled); + assert_eq!(target.subject, None, "personal invite carries no subject"); + assert_eq!( + target.device_key_dir, + trace_contribution_dir_for_scope_at(dir.path(), Some(scope.as_str())), + "personal enrollment loads its device key from the per-scope dir" + ); + } + + #[test] + fn effective_flush_target_instance_only_uses_instance_dir_and_subject() { + let dir = tempfile::tempdir().unwrap(); + let scope = trace_scope_key("tenant-a", "alice"); + // No personal policy for the scope; only the instance-level (None) policy. + let instance = StandingTraceContributionPolicy { + enabled: true, + ..Default::default() + }; + write_policy_at(dir.path(), None, &instance); + + let target = resolve_effective_flush_target_at(dir.path(), Some(scope.as_str())) + .unwrap() + .expect("instance-enrolled scope is a contributing target (gate must not abort)"); + assert!(target.policy.enabled); + assert_eq!( + target.subject, + Some(local_pseudonymous_contributor_id(&scope)), + "instance enrollment attributes the user via a per-user pseudonymous subject" + ); + assert_eq!( + target.device_key_dir, + trace_contribution_dir_for_scope_at(dir.path(), None), + "instance enrollment loads the shared device key from the instance (None) dir" + ); + } + + #[test] + fn effective_flush_target_none_when_unenrolled() { + let dir = tempfile::tempdir().unwrap(); + let scope = trace_scope_key("tenant-a", "alice"); + // Empty dir — neither a personal nor an instance policy is enabled. + assert!( + resolve_effective_flush_target_at(dir.path(), Some(scope.as_str())) + .unwrap() + .is_none(), + "unenrolled scope has no contributing target" + ); + } + #[test] fn upload_claim_request_includes_subject_in_device_key_mode() { let policy = StandingTraceContributionPolicy { From d4a6b3b446cc42e331d7349606093207bada34f4 Mon Sep 17 00:00:00 2001 From: Zaki Date: Thu, 25 Jun 2026 20:38:51 -0700 Subject: [PATCH 23/46] fix(traces): include per-user subject in upload-claim cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under instance enrollment every user shares the same instance device-key dir (scope None), so the upload-claim cache key — which keyed on scope_dir but not subject — collided across users. A bearer minted for one subject could be served from cache to another, mis-attributing traces / leaking across users. Add a hashed subject component to the DeviceKey cache key and a regression test proving two subjects sharing a scope_dir get distinct keys (and a no-subject personal-invite context stays distinct from both). Found by Codex review of PR #5280. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/contribution.rs | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/ironclaw_reborn_traces/src/contribution.rs b/crates/ironclaw_reborn_traces/src/contribution.rs index 28f9c129c56..c82b9f621d8 100644 --- a/crates/ironclaw_reborn_traces/src/contribution.rs +++ b/crates/ironclaw_reborn_traces/src/contribution.rs @@ -5039,8 +5039,22 @@ fn trace_upload_claim_cache_key( .unwrap_or_default(), TraceUploadAuthMode::WorkloadTokenEnv => String::new(), }; + // Under instance enrollment every user shares the SAME instance device-key + // dir (scope `None`), so `scope_dir_key` is identical across users — the + // per-user `subject` is what distinguishes their minted claims. Omitting it + // would let a claim minted for one subject be served from cache to another, + // mis-attributing traces / leaking across users. Hash it for parity with the + // other key components (the subject is already an opaque pseudonym, but the + // hash keeps the cache key uniform and bounded). + let subject_key = context + .subject + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| format!("sha256:{}", hex::encode(Sha256::digest(s.as_bytes())))) + .unwrap_or_default(); Ok(format!( - "{}|tenant={}|audience={}|scopes={}|uses={}|workload_env={}|invite_code={}|scope_dir={}", + "{}|tenant={}|audience={}|scopes={}|uses={}|workload_env={}|invite_code={}|scope_dir={}|subject={}", issuer, policy.upload_token_tenant_id.as_deref().unwrap_or_default(), policy.upload_token_audience.as_deref().unwrap_or_default(), @@ -5052,6 +5066,7 @@ fn trace_upload_claim_cache_key( .unwrap_or_default(), invite_code_key, scope_dir_key, + subject_key, )) } @@ -15185,6 +15200,60 @@ mod tests { assert_eq!(json["subject"], "sha256:deadbeef"); } + #[test] + fn upload_claim_cache_key_separates_subjects_sharing_a_scope_dir() { + // Instance enrollment: all users share the SAME instance device-key dir + // (scope None), distinguished only by their per-user subject. The cache + // key MUST differ per subject, or a claim minted for one user would be + // served from cache to another (cross-user trace mis-attribution). + let policy = StandingTraceContributionPolicy { + enabled: true, + auth_mode: TraceUploadAuthMode::DeviceKey, + upload_token_issuer_url: Some( + "https://issuer.example/v1/trace-upload-claim".to_string(), + ), + upload_token_tenant_id: Some("tenant-a".to_string()), + upload_token_audience: Some("trace-commons".to_string()), + ..Default::default() + }; + let shared_dir = std::path::PathBuf::from("/instance/trace_contributions"); + let ctx_for = |subject: &str| TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: vec![ConsentScope::DebuggingEvaluation], + allowed_uses: Vec::new(), + scope_dir: Some(shared_dir.clone()), + subject: Some(subject.to_string()), + }; + + let alice = trace_upload_claim_cache_key(&policy, &ctx_for("sha256:alice")).unwrap(); + let bob = trace_upload_claim_cache_key(&policy, &ctx_for("sha256:bob")).unwrap(); + let alice_again = trace_upload_claim_cache_key(&policy, &ctx_for("sha256:alice")).unwrap(); + + assert_ne!( + alice, bob, + "distinct subjects sharing a scope_dir must get distinct cache keys" + ); + assert_eq!( + alice, alice_again, + "same subject must produce a stable cache key" + ); + + // A no-subject context (personal-invite path) must also differ from the + // subject-bearing keys so the two models never collide on cache. + let no_subject = TraceUploadClaimContext { + trace_id: None, + submission_id: None, + consent_scopes: vec![ConsentScope::DebuggingEvaluation], + allowed_uses: Vec::new(), + scope_dir: Some(shared_dir.clone()), + subject: None, + }; + let none_key = trace_upload_claim_cache_key(&policy, &no_subject).unwrap(); + assert_ne!(alice, none_key); + assert_ne!(bob, none_key); + } + #[test] fn context_with_subject_sets_field() { let ctx = TraceUploadClaimContext { From c6e4f435cf89f13fc221567f56936643d9b17075 Mon Sep 17 00:00:00 2001 From: Zaki Date: Fri, 26 Jun 2026 17:13:11 -0700 Subject: [PATCH 24/46] fix(traces): address CodeRabbit review on PR #5280 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account_login_link manifest: declare ReadFilesystem effect (it reads local enrollment/policy/device-key state before egress), matching profile_token. (CR #2) - account-traces fetch: always send a bounded, clamped limit ([1, 500], default 200) so None never triggers an unbounded server history fetch. (CR #3) - direct fetch path: bound the response body with a hard byte ceiling (256 KiB) via a chunked bounded reader, instead of buffering unbounded. (CR #5) - account-traces fetch (both sink + direct): stop swallowing every non-2xx as an empty list — 404 = legitimate empty (no account yet), all other non-2xx surface as Err so the WebUI renders a sanitized unavailable state. Add regression tests (500 -> err, 404 -> empty). (CR #6) - trace-commons-tab.js: render missing final_credit as "—" not "0.00"; surface useAccountTraces() query errors instead of collapsing them to "no traces". (CR #7, #8) - handlers contract test: capture the forwarded caller in the trace_account_traces stub and assert the route threads the authenticated user id (test-through-the-caller). (CR #9) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/first_party_tools/trace_commons.rs | 8 +- .../src/contribution.rs | 175 ++++++++++++++++-- .../tests/webui_v2_handlers_contract.rs | 26 ++- .../static/js/i18n/en.js | 1 + .../settings/components/trace-commons-tab.js | 18 +- 5 files changed, 211 insertions(+), 17 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs index d75de57761d..fd4916f4942 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/trace_commons.rs @@ -194,7 +194,13 @@ pub(super) fn account_login_link_manifest() -> Result anyhow::Result> { + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| anyhow::anyhow!("failed to read account traces response body: {e}"))? + { + bytes.extend_from_slice(&chunk); + anyhow::ensure!( + bytes.len() <= ACCOUNT_TRACES_MAX_RESPONSE_BYTES, + "account traces response exceeded {} bytes", + ACCOUNT_TRACES_MAX_RESPONSE_BYTES + ); + } + Ok(bytes) +} + /// Parse the issuer's typed error label out of an error response body. /// The issuer returns `{"error": "