Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions crates/ironclaw_product_workflow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,14 @@ pub use reborn_services::{
OperatorServiceLifecycleService, OperatorStatusService, OutboundPreferencesProductFacade,
ProductAgentBoundCaller, ProjectCaller, ProjectFilesystemReader, ProjectFsEntry,
ProjectFsEntryKind, ProjectFsError, ProjectFsFile, ProjectFsStat, ProjectService,
ProjectServiceError, RebornAccountTrace, RebornAccountTracesResponse, RebornAddMemberRequest,
RebornAdminCreateUserRequest, RebornAdminPutSecretRequest, RebornAdminSecretDeletedResponse,
RebornAdminSecretResponse, RebornAdminSetRoleRequest, RebornAdminSetStatusRequest,
RebornAdminUpdateUserRequest, RebornAdminUserCreatedResponse, RebornAdminUserDeletedResponse,
RebornAdminUserListQuery, RebornAdminUserListResponse, RebornAdminUserResponse,
RebornAdminUserSecretsListResponse, RebornAttachmentBytes, RebornAttachmentRequest,
RebornAutomationInfo, RebornAutomationMutationResponse, RebornAutomationRecentRunInfo,
ProjectServiceError, RebornAccountLoginLinkResponse, RebornAccountTrace,
RebornAccountTracesResponse, RebornAddMemberRequest, RebornAdminCreateUserRequest,
RebornAdminPutSecretRequest, RebornAdminSecretDeletedResponse, RebornAdminSecretResponse,
RebornAdminSetRoleRequest, RebornAdminSetStatusRequest, RebornAdminUpdateUserRequest,
RebornAdminUserCreatedResponse, RebornAdminUserDeletedResponse, RebornAdminUserListQuery,
RebornAdminUserListResponse, RebornAdminUserResponse, RebornAdminUserSecretsListResponse,
RebornAttachmentBytes, RebornAttachmentRequest, RebornAutomationInfo,
RebornAutomationMutationResponse, RebornAutomationRecentRunInfo,
RebornAutomationRecentRunStatus, RebornAutomationRunStatus, RebornAutomationSource,
RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction,
RebornChannelConnectStrategy, RebornConnectableChannelInfo,
Expand Down
27 changes: 25 additions & 2 deletions crates/ironclaw_product_workflow/src/reborn_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ pub use admin_users::{
};
pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind};
pub use trace_credits::{
RebornAccountTrace, RebornAccountTracesResponse, RebornTraceCreditsResponse,
RebornTraceHoldAuthorizeResponse,
RebornAccountLoginLinkResponse, RebornAccountTrace, RebornAccountTracesResponse,
RebornTraceCreditsResponse, RebornTraceHoldAuthorizeResponse,
};

pub use fs_browse::{
Expand Down Expand Up @@ -2097,6 +2097,29 @@ pub trait RebornServicesApi: Send + Sync {
.map_err(RebornServicesError::internal_from)
}

/// Mint a one-time Trace Commons browser login link for the
/// authenticated caller, so hosted users (no host-file access) can open
/// their contributor account from the WebUI.
///
/// The scope is always derived from the authenticated caller's tenant +
/// user id — never from request input. An unenrolled caller gets the
/// zero-state (`minted: false, enrolled: false`), never an error.
///
/// SECURITY: the returned URL is a one-time account-access credential. It
/// travels only over this authenticated response to the caller's own
/// browser; it must never be logged or exposed on a model-visible
/// surface. The default body is the production implementation (pinned
/// direct client, same lane as `trace_account_traces`).
async fn trace_account_login_link(
&self,
caller: WebUiAuthenticatedCaller,
) -> Result<RebornAccountLoginLinkResponse, RebornServicesError> {
let actor = caller.actor();
trace_credits::account_login_link_for_user(&caller.tenant_id, &actor.user_id)
.await
.map_err(RebornServicesError::internal_from)
}

/// Authorize the caller's held manual-review trace for submission
/// (promote-as-is). The scope is always the authenticated caller's user
/// id; the submission id from the request path is never authority to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
use chrono::{DateTime, Utc};
use ironclaw_host_api::{TenantId, UserId};
use ironclaw_reborn_traces::contribution::{
authorize_manual_review_hold_for_scope, fetch_account_traces, read_trace_policy_for_scope,
resolve_trace_credentials, scoped_credit_view,
AccountLoginLinkError, authorize_manual_review_hold_for_scope, fetch_account_traces,
mint_account_login_link, read_trace_policy_for_scope, resolve_trace_credentials,
scoped_credit_view,
};
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -101,6 +102,59 @@ pub struct RebornTraceHoldAuthorizeResponse {
pub authorized: bool,
}

/// One-time Trace Commons browser login link, minted for the authenticated
/// caller. SECURITY: the `url` is a code-bearing account-access credential.
/// It is delivered ONLY over the authenticated WebUI response to the caller's
/// own browser — it must never be logged, persisted, or placed on any
/// model-visible surface.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RebornAccountLoginLinkResponse {
/// Whether a link was minted. `false` with `enrolled: false` is the
/// unenrolled zero-state, not an error.
pub minted: bool,
pub enrolled: bool,
/// The one-time login URL (present iff `minted`). Expires shortly and is
/// single-use.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}

/// Typed failure for [`account_login_link_for_user`]. Mirrors
/// [`AccountTracesError`]: the operation is named and the cause chain is
/// preserved for a diagnosable (but sanitized at the wire) 500.
#[derive(Debug, thiserror::Error)]
pub(super) enum AccountLoginLinkMintError {
/// Minting the link failed (policy read, claim mint, transport, issuer).
#[error("mint account login link: {0}")]
Mint(String),
}

/// Mint a one-time Trace Commons browser login link for the caller.
///
/// Uses the crate-local pinned direct client (no host-egress sink) — the same
/// network lane as [`account_traces_for_user`]. An unenrolled caller gets the
/// zero-state (`minted: false, enrolled: false`), never an error; hosted
/// multi-tenant users have no host-file access, so the authenticated response
/// is the only delivery channel for the link.
pub(super) async fn account_login_link_for_user(
tenant_id: &TenantId,
user_id: &UserId,
) -> Result<RebornAccountLoginLinkResponse, AccountLoginLinkMintError> {
match mint_account_login_link(tenant_id, user_id).await {
Ok(link) => Ok(RebornAccountLoginLinkResponse {
minted: true,
enrolled: true,
url: Some(link.url),
}),
Err(AccountLoginLinkError::NotEnrolled) => Ok(RebornAccountLoginLinkResponse {
minted: false,
enrolled: false,
url: None,
}),
Err(error) => Err(AccountLoginLinkMintError::Mint(format!("{error:#}"))),
}
}

/// Typed failure for [`account_traces_for_user`]. Each variant names the backend
/// operation that failed and carries the full cause chain (`{:#}`) so the WebUI
/// boundary keeps a diagnosable cause instead of an undiscriminated `String`.
Expand Down
6 changes: 6 additions & 0 deletions crates/ironclaw_reborn_cli/src/commands/traces/contributor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ pub(super) async fn dispatch(cmd: TracesSubcommand) -> anyhow::Result<()> {
min_submission_score,
}),
TracesSubcommand::OptOut { user_scope } => opt_out(user_scope.as_deref()),
TracesSubcommand::EnrollInstance {
invite,
include_message_text,
include_tool_payloads,
json,
} => enroll_instance(&invite, include_message_text, include_tool_payloads, json).await,
TracesSubcommand::Status { json, user_scope } => {
show_policy_status(json, user_scope.as_deref())
}
Expand Down
119 changes: 114 additions & 5 deletions crates/ironclaw_reborn_cli/src/commands/traces/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,42 @@ enum TracesSubcommand {
min_submission_score: f32,
},

/// Disable autonomous trace contribution
/// Disable autonomous trace contribution. With --user-scope: opt out ONLY
/// that user (instance-level enrollment untouched). Without: disable the
/// global/instance policy AND the owner scope (full off switch).
OptOut {
/// Runtime/web user scope to disable; defaults to this instance's owner_id
/// Runtime/web user scope to disable (scoped-only opt-out); defaults
/// to this instance's owner_id AND disables the global policy
#[arg(long)]
user_scope: Option<String>,
},

/// Enroll this ENTIRE INSTANCE in Trace Commons with an operator invite
/// link (admin operation — requires shell access to the instance host).
/// Every user without a personal enrollment inherits it, attributed via a
/// salted per-user pseudonym. Exclude a single user with
/// `traces opt-out --user-scope <tenant-id>/<user-id>` (bare
/// `traces opt-out` disables the entire instance enrollment).
EnrollInstance {
/// Operator invite link (`https://<host>#<code>`, or `<code>@<host>`)
#[arg(long)]
invite: String,

/// Include locally redacted user/assistant message text in envelopes
/// (applies instance-wide to every inheriting user)
#[arg(long)]
include_message_text: bool,

/// Include locally redacted tool arguments, tool results, and HTTP
/// bodies in envelopes (applies instance-wide)
#[arg(long)]
include_tool_payloads: bool,

/// Output the enrollment outcome as JSON
#[arg(long)]
json: bool,
},

/// Show local standing trace contribution policy
Status {
/// Output as JSON
Expand Down Expand Up @@ -399,6 +428,7 @@ async fn run_traces(cmd: TracesSubcommand) -> anyhow::Result<()> {
match cmd {
v @ TracesSubcommand::OptIn { .. } => contributor::dispatch(v).await,
v @ TracesSubcommand::OptOut { .. } => contributor::dispatch(v).await,
v @ TracesSubcommand::EnrollInstance { .. } => contributor::dispatch(v).await,
v @ TracesSubcommand::Status { .. } => contributor::dispatch(v).await,
v @ TracesSubcommand::Preview { .. } => contributor::dispatch(v).await,
v @ TracesSubcommand::Enqueue { .. } => contributor::dispatch(v).await,
Expand Down Expand Up @@ -522,15 +552,94 @@ fn opt_in(options: OptInOptions) -> anyhow::Result<()> {
}

fn opt_out(user_scope: Option<&str>) -> anyhow::Result<()> {
let explicit_scope = user_scope.is_some();
let runtime_scope = trace_runtime_user_scope(user_scope)?;
if explicit_scope {
// Per-user opt-out: write ONLY the scoped policy. The root policy is
// the instance-wide enrollment (inherited by every user without a
// personal enrollment); flipping it here would disenroll the entire
// instance to opt out one user.
ironclaw_reborn_traces::contribution::opt_out_user_scope(&runtime_scope)?;
println!("Trace contribution disabled for user scope: {runtime_scope}");
println!(
"The instance-level enrollment (if any) is unchanged; other users keep contributing."
);
return Ok(());
}
// No explicit scope: legacy full disable — the global/instance policy AND
// the owner's scope. This is the admin-facing "turn it all off" path.
let mut policy = read_policy()?;
policy.enabled = false;
write_policy(&policy)?;
let mut scoped_policy = read_trace_policy_for_scope(Some(&runtime_scope))?;
scoped_policy.enabled = false;
write_trace_policy_for_scope(Some(&runtime_scope), &scoped_policy)?;
ironclaw_reborn_traces::contribution::opt_out_user_scope(&runtime_scope)?;
println!("Trace contribution opt-in disabled. Queued envelopes remain local.");
println!("Runtime/web trace scope disabled: {runtime_scope}");
println!(
"NOTE: this also disabled the global/instance-level policy. To opt out a single \
user instead, pass --user-scope <tenant-id>/<user-id>."
);
Ok(())
}

/// Instance-wide Trace Commons enrollment via an operator invite link.
/// Host-shell possession is the admin gate (same trust boundary as `opt-in`
/// writing the global policy). Writes the DeviceKey policy + device keypair
/// at the instance (scope-`None`) location, which every user without a
/// personal enrollment inherits with a salted per-user pseudonymous subject.
async fn enroll_instance(
invite: &str,
include_message_text: bool,
include_tool_payloads: bool,
json: bool,
) -> anyhow::Result<()> {
let consents = ironclaw_reborn_traces::onboarding::OnboardConsents {
include_message_text,
include_tool_payloads,
};
let outcome = ironclaw_reborn_traces::onboarding::onboard_instance_at_base(
&ironclaw_reborn_traces::paths::ironclaw_base_dir(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use Reborn home for instance enrollment state

When ironclaw-reborn traces enroll-instance is run with IRONCLAW_REBORN_HOME set or the default Reborn home (~/.ironclaw/reborn) differs from the legacy base, this line still writes the policy and device key under ironclaw_base_dir() (IRONCLAW_BASE_DIR/~/.ironclaw). That ignores the Reborn CLI state root and violates the crate contract in crates/ironclaw_reborn_cli/AGENTS.md to use IRONCLAW_REBORN_HOME, so an admin can enroll the wrong/legacy state tree instead of the intended Reborn instance.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified against the runtime and keeping ironclaw_base_dir() deliberately — this one's a false positive at the behavior level. The ENTIRE trace-commons subsystem lives under IRONCLAW_BASE_DIR/~/.ironclaw/trace_contributions: the runtime resolver the serve loop uses (resolve_trace_credentials → ironclaw_common::paths::ironclaw_base_dir()), the capture gate and flush worker in ironclaw_reborn_composition, the existing traces opt-in/opt-out/status/queue commands, and the agent capabilities. If enroll-instance wrote under IRONCLAW_REBORN_HOME instead, the enrollment would land where the resolver never reads and the feature would silently not work. IRONCLAW_REBORN_HOME currently scopes providers.json and extension state, not trace contributions — the AGENTS.md line guards against writing v1 state, and trace_contributions/ is the shared trace-commons state root the Reborn runtime itself consumes. If the subsystem migrates to the Reborn home it must migrate wholesale (resolver + all commands + worker) in one change, not one command at a time; happy to file that as a tracked follow-up if the migration is wanted.

invite,
consents,
)
.await
.map_err(|e| anyhow::anyhow!("instance enrollment failed: {e}"))?;

if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"enrolled": true,
"tenant_id": outcome.tenant_id,
"ingest_url": outcome.ingest_url,
"issuer_url": outcome.issuer_url,
"device_key_id": outcome.device_key_id,
"include_message_text": include_message_text,
"include_tool_payloads": include_tool_payloads,
}))
.map_err(|e| anyhow::anyhow!("failed to serialize enrollment outcome: {e}"))?
);
return Ok(());
}

println!("Instance enrolled in Trace Commons.");
println!(" tenant: {}", outcome.tenant_id);
println!(" ingest: {}", outcome.ingest_url);
println!(" issuer: {}", outcome.issuer_url);
println!(" device key: {}", outcome.device_key_id);
println!(
" consents: message_text={include_message_text} tool_payloads={include_tool_payloads}"
);
println!();
println!(
"All users without a personal enrollment now contribute under this instance \
enrollment, attributed via salted per-user pseudonyms."
);
println!(
"Opt a single user out with `ironclaw-reborn traces opt-out --user-scope \
<tenant-id>/<user-id>`; an explicit opt-out always wins over instance enrollment \
(bare `traces opt-out` disables the whole instance enrollment)."
);
println!("Verify with `ironclaw-reborn traces status --json` and `traces ingest-health`.");
Ok(())
}

Expand Down
37 changes: 37 additions & 0 deletions crates/ironclaw_reborn_cli/src/commands/traces/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,3 +853,40 @@ fn queue_status_diagnostics_reports_invite_code_configured() {
"absent invite code must report configured=false"
);
}

#[test]
fn enroll_instance_parses_invite_and_consent_flags() {
let cli = parse_cli([
"ironclaw-reborn",
"traces",
"enroll-instance",
"--invite",
"https://commons.example#INVADMIN1",
"--include-message-text",
"--json",
]);

let TracesSubcommand::EnrollInstance {
invite,
include_message_text,
include_tool_payloads,
json,
} = unwrap_traces_command(cli)
else {
panic!("expected traces enroll-instance command");
};

assert_eq!(invite, "https://commons.example#INVADMIN1");
assert!(include_message_text);
assert!(
!include_tool_payloads,
"tool payloads must default to excluded"
);
assert!(json);
}

#[test]
fn enroll_instance_requires_invite() {
let result = parse_cli_result(["ironclaw-reborn", "traces", "enroll-instance"]);
assert!(result.is_err(), "--invite must be required");
}
Loading
Loading