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
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@ leading and trailing pipe. Columns, in order:
| reactor-chat-timeline | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | production chat message virtualization, row realization, and imperative scroll follow | ReactorChatTimeline through OpenClawReactorChatRoot and ReactorHostControl | OpenClawChatTimeline remains a legacy focused-test surface while its runtime route is migrated | the default chat route mounts one direct ReactorHostControl per XAML chat target; Reactor owns stable-key ItemsView and ItemContainer realization without a custom native list, collection reconciler, or scroll-layout mutation | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when Reactor timeline proof coverage replaces the legacy focused UI host coverage |
| chat-tool-activity-renderer | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | production standalone tool-call and grouped activity presentation, summaries, disclosures, and detail rendering | ChatToolActivityPresentation + ToolCallCardRenderer | ReactorChatTimeline projects rows and delegates realization only | consecutive invocation grouping preserves source chronology; stable group identity comes from session, generation, and first tool entry; selectable output remains capped at 240px | ChatToolActivityPresentationTests.Project_GroupsOnlyConsecutiveSpansOfAtLeastTwoTools | behavioral | - |
| chat-history-replay-projection | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | array-valued history content ordering projection | ChatHistoryReplayProjection | provider applies projected text and tool parts to the reducer | interleaved text, calls, and results replay in source order without clearing active tool correlation | OpenClawChatDataProviderTests.LoadHistoryAsync_InterleavedContentParts_PreserveChronologyAndCorrelation | behavioral | - |
| assistant-media-protocol-projection | authoritative | src/OpenClaw.Shared/OpenClawGatewayClient.cs | structured assistant media content parsing and assistant-only legacy MEDIA directive redaction/projection | AssistantMediaDirectiveParser + ChatMediaContentInfo | gateway client preserves ordered typed media while tray presentation receives only safe filenames and metadata | user text never activates media directives; accepted local sources never enter visible assistant text or notifications; media-only messages survive live and history parsing | AssistantMediaDirectiveParserTests.Project_AssistantAbsolutePath_ProducesMediaWithoutExposingPath | behavioral | - |
| assistant-media-resolver | authoritative | src/OpenClaw.Shared/OpenClawGatewayClient.cs | authenticated structured artifact and legacy assistant-media byte retrieval | OpenClawGatewayClient.AssistantMedia | chat bridge exposes only lease-bound typed resolution results; renderer never receives credentials, tickets, or arbitrary URLs | accepts only current-connection results, matching media MIME families, managed ticket paths, and payloads within the 12 MiB image or 16 MiB playback caps | OpenClawGatewayClientAssistantMediaTests.ResolveLegacyMedia_UsesBearerMetadataAndSourceBoundTicket | behavioral | - |
| provider-assistant-media-parsing-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | parsing legacy MEDIA directives or structured Gateway media blocks | AssistantMediaDirectiveParser + OpenClawGatewayClient | provider owns message identity, streaming reconciliation, timeline application, and safe presentation metadata orchestration | provider consumes typed content parts and never reparses model text or exposes raw media sources | ChatAssistantContentPresentationTests.Project_UsesSafeFilenameWithoutExposingLegacySource | behavioral | when assistant message ingestion leaves OpenClawChatDataProvider |
| assistant-media-renderer | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | assistant media card presentation, bounded image decode, retry, and row cancellation | ChatAssistantMediaRenderer | ReactorChatTimeline owns row placement and delegates media realization | at most four images render inline per message; unsupported or unresolved typed media remains visible as an accessible safe unavailable card; raw Gateway sources are never rendered | ChatAssistantContentPresentationTests.BuildRenderPlan_CapsImagesWithoutReorderingOtherMedia | behavioral | - |
| gateway-media-message-projection | authoritative | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | gateway media-envelope parsing, safe filename/MIME normalization, attachment signatures, and provenance-safe attachment descriptors | GatewayMediaMessageProjection + ChatAttachmentPresentation | provider applies the projection to live, reset, backfill, and history ingress and owns stateful echo correlation | gateway text never becomes a private marker; gateway descriptors have no preview key; only local opaque preview keys can access image bytes | GatewayMediaMessageProjectionTests.ValidEnvelope_ProjectsSafeDescriptorAndCleanProse | behavioral | - |
| provider-gateway-media-parsing-closed | closed | src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs | private gateway media-envelope parsing or descriptor construction | GatewayMediaMessageProjection | provider retains stateful pending-echo queues, reset gates, sidecar matching, and reducer application | all user ingress paths call the focused projection and do not independently parse gateway media text | review-only | review-only | when user-message ingestion leaves OpenClawChatDataProvider |
| reactor-tool-rendering-closed | closed | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | per-tool and grouped activity summary/detail rendering implementation | ToolCallCardRenderer | row projection, virtualization, hover state, assistant runs, and renderer delegation only | ReactorChatTimeline contains no tool detail renderer and delegates both standalone and grouped tool rows | ChatTimelinePresentationTests.ReactorTimeline_DelegatesToolAndActivityRenderingToFocusedOwner | source-shape | when ReactorChatTimeline is replaced as the production virtualization owner |
| functional-chat-default-mount | closed | src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs | mounting the FunctionalUI chat tree as the default ChatPage or ChatWindow surface | ReactorChatHostExtensions and OpenClawReactorChatRoot | legacy FunctionalUI chat files may remain for focused compatibility coverage only | ChatPage and ChatWindow mount the Reactor root directly into their existing ChatHost Borders; no FunctionalUI component mounts or nests Reactor on the default path | review-only: user explicitly deferred new tests for this migration; required build and existing shared/tray suites still run | review-only | when legacy FunctionalUI chat surfaces are removed |
| settings-store | authoritative | settings and permission UI surfaces | direct SettingsManager mutation and blanket self-write suppression | ISettingsStore | non-permission legacy surfaces may read SettingsManager until migrated; direct saves publish origin null | every save publishes one versioned event; only the matching writer ignores its own origin while all other active consumers refresh | SettingsSharedStateContractTests.TwoActiveSettingsPageViewModels_IgnoreOnlyOwnWrites_InBothDirections | behavioral | when every settings surface reads and writes through ISettingsStore |
Expand Down
3 changes: 2 additions & 1 deletion src/OpenClaw.Connection/GatewayClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public IGatewayClientLifecycle Create(
tokenIsBootstrapToken: credential.IsBootstrapToken,
bootstrapPairAsNode: false,
identityPath: identityPath,
ignoreStoredDeviceToken: credential.IsBootstrapToken);
ignoreStoredDeviceToken: credential.IsBootstrapToken,
assistantMediaAuthToken: credential.InteractiveHttpToken);

return new GatewayClientLifecycleAdapter(client);
}
Expand Down
165 changes: 155 additions & 10 deletions src/OpenClaw.Connection/GatewayConnectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,52 @@ await StopOwnedTunnelAfterFailedConnectionAsync(
IGatewayClientLifecycle lifecycle;
try
{
lifecycle = _clientFactory.Create(connectUrl, credential, perGatewayIdentityDir, diagLogger);
var httpCredential =
InteractiveGatewayCredentialResolver.ResolveForAssistantMediaHttpSurface(
record,
credential);
var interactiveHttpToken = string.Empty;
if (httpCredential is not null)
{
var httpAuthorization = await AuthorizeCredentialForEndpointAsync(
record,
httpCredential,
_operationCts!.Token)
.ConfigureAwait(false);
if (_disposed ||
Interlocked.Read(ref _generation) != gen ||
_operationCts?.IsCancellationRequested != false)
{
return;
}
if (httpAuthorization.Allowed)
{
interactiveHttpToken = httpCredential.Token;
}
else
{
_diagnostics.Record(
"credentials",
"Interactive HTTP credential was withheld",
httpAuthorization.Detail);
}
}
else
{
_diagnostics.Record(
"credentials",
"Interactive HTTP credential was unavailable");
}

var clientCredential = credential with
{
InteractiveHttpToken = interactiveHttpToken,
};
lifecycle = _clientFactory.Create(
connectUrl,
clientCredential,
perGatewayIdentityDir,
diagLogger);
}
catch (DeviceIdentityLoadException ex)
{
Expand Down Expand Up @@ -557,6 +602,11 @@ await StopOwnedTunnelAfterFailedConnectionAsync(
async Task<ReconnectAuthorizationResult> AuthorizeLiveCredentialHandoffAsync(
CancellationToken cancellationToken)
{
// Clear the assistant-media HTTP credential up front so any
// in-flight media request never outlives this handoff attempt;
// it is only restored below once the fresh credential (or its
// interactive HTTP fallback) is re-authorized for this generation.
lifecycle.DataClient.SetAssistantMediaAuthToken(null);
var authorization = await AuthorizeCredentialHandoffAsync(
record,
credential,
Expand All @@ -578,6 +628,58 @@ await RecordOperatorCredentialHandoffFailureAsync(
record.Id)
.ConfigureAwait(false);
}

if (authorization.Allowed &&
IsCurrentGatewayAttempt(gen, record.Id) &&
IsAutomaticReconnectAllowed(record.Id))
{
var currentRecord = _registry.GetById(record.Id);
if (currentRecord is not null)
{
GatewayCredential? currentHttpFallback = null;
if (string.IsNullOrWhiteSpace(currentRecord.SharedGatewayToken))
{
currentHttpFallback = _credentialResolver.ResolveOperator(
currentRecord,
perGatewayIdentityDir);
}
var reconnectHttpCredential =
InteractiveGatewayCredentialResolver.ResolveForAssistantMediaHttpSurface(
currentRecord,
currentHttpFallback);
if (reconnectHttpCredential is null)
{
_diagnostics.Record(
"credentials",
"Interactive HTTP credential was unavailable during reconnect");
}
else
{
var httpAuthorization = await AuthorizeCredentialForEndpointAsync(
currentRecord,
reconnectHttpCredential,
cancellationToken)
.ConfigureAwait(false);
if (IsCurrentGatewayAttempt(gen, record.Id) &&
IsAutomaticReconnectAllowed(record.Id))
{
if (httpAuthorization.Allowed)
{
lifecycle.DataClient.SetAssistantMediaAuthToken(
reconnectHttpCredential.Token);
}
else
{
_diagnostics.Record(
"credentials",
"Interactive HTTP credential was withheld during reconnect",
httpAuthorization.Detail);
}
}
}
}
}

return authorization;
}

Expand Down Expand Up @@ -2463,7 +2565,7 @@ private static bool IsSameCredentialHandoffRecord(
expected.BootstrapToken,
StringComparison.Ordinal) &&
current.IsLocal == expected.IsLocal &&
current.RequiresV2Signature == expected.RequiresV2Signature &&
(current.RequiresV2Signature || !expected.RequiresV2Signature) &&
string.Equals(
current.SetupManagedDistroName,
expected.SetupManagedDistroName,
Expand Down Expand Up @@ -2549,23 +2651,66 @@ private async Task HandleDeviceTokenReceivedAsync(
identityPath,
token,
CancellationToken.None).ConfigureAwait(false);
if (result.Outcome != DeviceTokenHandlingOutcome.IdentityLoadFailure)
if (result.Outcome != DeviceTokenHandlingOutcome.IdentityLoadFailure &&
result.Outcome != DeviceTokenHandlingOutcome.Stored)
{
return;
}

var gatewayRecordId = attempt.GatewayRecordId ?? string.Empty;
await _transitionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
if (!IsCurrentGatewayAttempt(
attempt.LifecycleGeneration,
attempt.GatewayRecordId ?? string.Empty))
if (!IsCurrentGatewayAttempt(attempt.LifecycleGeneration, gatewayRecordId))
{
return;
}

_stateMachine.TryTransition(
ConnectionTrigger.WebSocketError,
DeviceIdentityLoadException.RecoveryMessage);
EmitStateChanged();
if (result.Outcome == DeviceTokenHandlingOutcome.IdentityLoadFailure)
{
_stateMachine.TryTransition(
ConnectionTrigger.WebSocketError,
DeviceIdentityLoadException.RecoveryMessage);
EmitStateChanged();
return;
}

// Stored: refresh the assistant-media HTTP credential bound to the
// fresh operator device token when this connection relies on a
// device token rather than a shared gateway token.
if (!string.Equals(token.Role, "operator", StringComparison.OrdinalIgnoreCase))
return;

var currentRecord = _registry.GetById(gatewayRecordId);
if (currentRecord is null ||
!string.IsNullOrWhiteSpace(currentRecord.SharedGatewayToken))
{
return;
}

var deviceCredential = new GatewayCredential(
token.Token,
IsBootstrapToken: false,
CredentialResolver.SourceDeviceToken);
var authorization = await AuthorizeCredentialForEndpointAsync(
currentRecord,
deviceCredential,
_operationCts?.Token ?? CancellationToken.None)
.ConfigureAwait(false);
if (!IsCurrentGatewayAttempt(attempt.LifecycleGeneration, gatewayRecordId))
return;
if (authorization.Allowed)
{
_activeLifecycle?.DataClient.SetAssistantMediaAuthToken(token.Token);
}
else
{
_activeLifecycle?.DataClient.SetAssistantMediaAuthToken(null);
_diagnostics.Record(
"credentials",
"Interactive HTTP device credential was withheld after token refresh",
authorization.Detail);
}
}
finally
{
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Connection/ICredentialResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public sealed record GatewayCredential(string Token, bool IsBootstrapToken, stri
GatewayCredentialResolutionStatus.Resolved;
public bool FallbackUsed { get; init; }
public string? ResolutionDetail { get; init; }
public string? InteractiveHttpToken { get; init; }
}

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ namespace OpenClaw.Connection;
/// </summary>
public static class InteractiveGatewayCredentialResolver
{
internal static GatewayCredential? ResolveForAssistantMediaHttpSurface(
GatewayRecord record,
GatewayCredential? fallback) =>
!string.IsNullOrWhiteSpace(record.SharedGatewayToken)
? new GatewayCredential(
record.SharedGatewayToken!,
IsBootstrapToken: false,
CredentialResolver.SourceSharedGatewayToken)
: fallback is
{
IsBootstrapToken: false,
Source: CredentialResolver.SourceDeviceToken,
}
? fallback
: null;

public static bool TryResolve(
GatewayRegistry? registry,
string settingsDirectory,
Expand Down
Loading
Loading