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
8 changes: 8 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>

<PropertyGroup Condition="'$(MSBuildProjectName)' == 'OpenClaw.Gateway'">
<OpenClawFeatureVariant>standard</OpenClawFeatureVariant>
<OpenClawFeatureVariant Condition="'$(OpenClawEnableMafExperiment)' == 'true'">$(OpenClawFeatureVariant)-maf</OpenClawFeatureVariant>
<OpenClawFeatureVariant Condition="'$(OpenClawEnableOpenSandbox)' == 'true'">$(OpenClawFeatureVariant)-opensandbox</OpenClawFeatureVariant>
<BaseIntermediateOutputPath>obj/$(OpenClawFeatureVariant)/</BaseIntermediateOutputPath>
<DefaultItemExcludes>$(DefaultItemExcludes);obj/**</DefaultItemExcludes>
</PropertyGroup>

<ItemGroup Condition="'$(IsPackable)' == 'true'">
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
Expand Down
375 changes: 179 additions & 196 deletions README.md

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Recently Completed

- **Channel expansion**: Discord (Gateway WebSocket + interaction webhook), Slack (Events API + slash commands), Signal (signald/signal-cli bridge) channel adapters with DM policy, allowlists, thread-to-session mapping, and signature validation.
- **Tool expansion** (34 → 48 native tools): edit_file, apply_patch, message, x_search, memory_get, sessions_history, sessions_send, sessions_spawn, session_status, sessions_yield, agents_list, cron, gateway, profile_write.
- **Tool presets and groups**: 4 new built-in presets (full, coding, messaging, minimal) and 7 built-in tool groups (group:runtime, group:fs, group:sessions, group:memory, group:web, group:automation, group:messaging).
- **Chat commands**: /think (reasoning effort), /compact (history compaction), /verbose (tool call/token output).
- **Multi-agent routing**: per-channel/sender routing with model override, route-scoped prompt instructions, tool presets, and tool allowlist restrictions.
- **Integrations**: Tailscale Serve/Funnel, Gmail Pub/Sub event bridge, mDNS/Bonjour service discovery.
- **Plugin installer**: built-in `openclaw plugins install/remove/list/search` for npm/ClawHub packages.
- Security audit closure for plugin IPC hardening, plugin-root containment, browser cancellation recovery, strict session-cap admission, and session-lock disposal.
- Admin/operator tooling:
- posture diagnostics
Expand All @@ -12,6 +19,56 @@
- Startup/runtime composition split into explicit service, channel, plugin, and runtime assembly stages.
- Optional native Notion scratchpad integration with scoped read/write tools (`notion`, `notion_write`), allowlists, and write approvals by default.

## Runtime and Platform Expansion

These are strong candidates for the next roadmap phases because they extend the current runtime, channel, and operator model without fighting the existing architecture.

### Multimodal and Input Expansion

4. **Voice memo transcription**
- Detect inbound audio across supported channels and route it through a transcription provider.
- Inject transcript text into the runtime before the normal agent turn starts.
- Provide clear degraded behavior when transcription is disabled or unavailable.

5. **Checkpoint and resume for long-running tasks**
- Persist structured save points during multi-step execution.
- Allow interrupted or restarted sessions to resume from the last completed checkpoint.
- Start with checkpointing after successful tool batches instead of trying to snapshot every internal runtime state transition.

6. **Mixture-of-agents execution**
- Fan out a prompt to multiple providers and synthesize a final answer from their outputs.
- Expose this as an optional high-cost/high-confidence runtime mode or explicit tool.
- Keep it profile-driven so it can be limited to selected models and use cases.

### Execution and Deployment Options

7. **Daytona execution backend**
- Add a remote workspace backend with hibernation and resume support.
- Fit it into the existing `IExecutionBackend` and process execution model rather than adding a separate tool path.
- Useful for persistent remote development-style sandboxes.

8. **Modal execution backend**
- Add a serverless execution backend for short-lived compute-heavy tasks.
- Focus on one-shot and bounded process execution first.
- Treat GPU-enabled workloads as an optional extension once the base backend is stable.

### Operator Visibility and Safety

9. **CLI/TUI insights**
- Add an `openclaw insights` command and matching TUI panel.
- Summarize provider usage, token spend, tool frequency, and session counts from existing telemetry.
- Prefer operator-readable summaries over introducing a new analytics subsystem.

10. **URL safety validation**
- Add SSRF-oriented URL validation in web fetch and browser tooling.
- Block loopback/private targets by default and allow optional blocklists.
- Keep this configurable, but make the safe path easy to enable globally.

11. **Trajectory export**
- Export prompts, tool calls, results, and responses as JSONL for analysis or training pipelines.
- Support date-range or session-scoped export plus optional anonymization.
- Expose it through admin and CLI surfaces instead of burying it in storage internals.

## Security Hardening (Likely Breaking)

These are worthwhile changes, but they can break existing deployments or require new configuration.
Expand Down
36 changes: 28 additions & 8 deletions src/OpenClaw.Agent/AgentRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ public async Task<string> RunAsync(
: null
};

if (!string.IsNullOrWhiteSpace(session.ReasoningEffort))
{
chatOptions.AdditionalProperties ??= new AdditionalPropertiesDictionary();
chatOptions.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort;
}

for (var i = 0; i < _maxIterations; i++)
{
// Mid-turn budget check: stop if token budget is exceeded
Expand Down Expand Up @@ -392,6 +398,12 @@ public async IAsyncEnumerable<AgentStreamEvent> RunStreamingAsync(
Tools = _toolExecutor.GetToolDeclarations(session)
};

if (!string.IsNullOrWhiteSpace(session.ReasoningEffort))
{
chatOptions.AdditionalProperties ??= new AdditionalPropertiesDictionary();
chatOptions.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort;
}

for (var i = 0; i < _maxIterations; i++)
{
// Mid-turn budget check: stop if token budget is exceeded
Expand Down Expand Up @@ -1089,7 +1101,7 @@ private static bool IsTransient(Exception ex)
/// Compacts session history by summarizing older turns via the LLM.
/// Keeps the most recent turns verbatim and replaces older ones with a summary.
/// </summary>
internal async Task CompactHistoryAsync(Session session, CancellationToken ct)
public async Task CompactHistoryAsync(Session session, CancellationToken ct)
{
if (session.History.Count <= _compactionThreshold)
{
Expand Down Expand Up @@ -1187,15 +1199,9 @@ internal async Task CompactHistoryAsync(Session session, CancellationToken ct)

private List<ChatMessage> BuildMessages(Session session)
{
string systemPrompt;
lock (_skillGate)
{
systemPrompt = _systemPrompt;
}

var messages = new List<ChatMessage>
{
new(ChatRole.System, systemPrompt)
new(ChatRole.System, GetSystemPrompt(session))
};

// Add history (bounded to avoid context overflow)
Expand Down Expand Up @@ -1227,6 +1233,20 @@ private List<ChatMessage> BuildMessages(Session session)
return messages;
}

private string GetSystemPrompt(Session session)
{
string systemPrompt;
lock (_skillGate)
{
systemPrompt = _systemPrompt;
}

if (string.IsNullOrWhiteSpace(session.SystemPromptOverride))
return systemPrompt;

return systemPrompt + "\n\n[Route Instructions]\n" + session.SystemPromptOverride.Trim();
}

private static IList<AIContent> BuildTurnContents(string content)
{
var (markers, remainingText) = MediaMarkerProtocol.Extract(content);
Expand Down
24 changes: 17 additions & 7 deletions src/OpenClaw.Agent/OpenClawToolExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,9 @@ public OpenClawToolExecutor(

public IList<AITool> GetToolDeclarations(Session session)
{
if (_toolPresetResolver is null)
return _toolDeclarations;

var preset = _toolPresetResolver.Resolve(session, _toolsByName.Keys);
var preset = _toolPresetResolver?.Resolve(session, _toolsByName.Keys);
return _toolDeclarations
.Where(item => preset.AllowedTools.Contains(item.Name))
.Where(item => IsToolAllowedForSession(session, item.Name, preset))
.ToArray();
}

Expand Down Expand Up @@ -140,9 +137,11 @@ public async Task<ToolExecutionResult> ExecuteAsync(
}

var preset = _toolPresetResolver?.Resolve(session, _toolsByName.Keys);
if (preset is not null && !preset.AllowedTools.Contains(tool.Name))
if (!IsToolAllowedForSession(session, tool.Name, preset))
{
var deniedByPreset = $"Tool '{tool.Name}' is not allowed for preset '{preset.PresetId}'.";
var deniedByPreset = preset is not null
? $"Tool '{tool.Name}' is not allowed for preset '{preset.PresetId}'."
: $"Tool '{tool.Name}' is not allowed for this session.";
_logger?.LogInformation("[{CorrelationId}] {Message}", turnCtx.CorrelationId, deniedByPreset);
return CreateImmediateResult(toolName, argsJson, deniedByPreset);
}
Expand Down Expand Up @@ -308,6 +307,17 @@ private static ToolExecutionResult CreateImmediateResult(string toolName, string
};
}

private static bool IsToolAllowedForSession(Session session, string toolName, ResolvedToolPreset? preset)
{
if (preset is not null && !preset.AllowedTools.Contains(toolName))
return false;

if (session.RouteAllowedTools is { Length: > 0 })
return session.RouteAllowedTools.Contains(toolName, StringComparer.OrdinalIgnoreCase);

return true;
}

private async Task<string> ExecuteStreamingToolCollectAsync(
IStreamingTool tool,
string argsJson,
Expand Down
151 changes: 151 additions & 0 deletions src/OpenClaw.Agent/Tools/ApplyPatchTool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using OpenClaw.Core.Abstractions;
using OpenClaw.Core.Models;

namespace OpenClaw.Agent.Tools;

/// <summary>
/// Apply a unified diff patch to a file. Supports multi-hunk patches.
/// </summary>
public sealed class ApplyPatchTool : ITool
{
private readonly ToolingConfig _config;

public ApplyPatchTool(ToolingConfig config) => _config = config;

public string Name => "apply_patch";
public string Description => "Apply a unified diff patch to a file. Supports multi-hunk patches for complex edits.";
public string ParameterSchema => """{"type":"object","properties":{"path":{"type":"string","description":"File path to patch"},"patch":{"type":"string","description":"Unified diff patch content (lines starting with +/- and @@ hunk headers)"}},"required":["path","patch"]}""";

public async ValueTask<string> ExecuteAsync(string argumentsJson, CancellationToken ct)
{
if (_config.ReadOnlyMode)
return "Error: apply_patch is disabled because Tooling.ReadOnlyMode is enabled.";

using var args = System.Text.Json.JsonDocument.Parse(
string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
var root = args.RootElement;

var path = GetString(root, "path");
if (string.IsNullOrWhiteSpace(path))
return "Error: 'path' is required.";

var patch = GetString(root, "patch");
if (string.IsNullOrWhiteSpace(patch))
return "Error: 'patch' is required.";

Comment on lines +1 to +35
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

New file-manipulation tools (edit_file, apply_patch) are introduced without any unit tests, while the repo has extensive tool-level tests (e.g., FileReadToolTests, ToolPathPolicyTests). Add coverage for success paths and failure cases (path allow/deny, old_text uniqueness, multi-hunk patches, mismatch handling) to prevent accidental file corruption regressions.

Copilot generated this review using guidance from repository custom instructions.
var resolvedPath = ToolPathPolicy.ResolveRealPath(path);

if (!ToolPathPolicy.IsWriteAllowed(_config, resolvedPath))
return $"Error: Write access denied for path: {path}";

if (!File.Exists(resolvedPath))
return $"Error: File not found: {path}";

var originalLines = await File.ReadAllLinesAsync(resolvedPath, ct);
var hunks = ParseHunks(patch);

if (hunks.Count == 0)
return "Error: No valid hunks found in patch. Use @@ -start,count +start,count @@ headers.";

var result = new List<string>(originalLines);
var offset = 0;

foreach (var hunk in hunks)
{
var startLine = hunk.OriginalStart - 1 + offset;
if (startLine < 0 || startLine > result.Count)
return $"Error: Hunk at line {hunk.OriginalStart} is out of range (file has {result.Count} lines).";

// Validate removed lines match file content
if (startLine + hunk.RemoveLines.Count > result.Count)
return $"Error: Hunk at line {hunk.OriginalStart} expects {hunk.RemoveLines.Count} lines to remove, but only {result.Count - startLine} lines remain.";

for (var i = 0; i < hunk.RemoveLines.Count; i++)
{
var expected = hunk.RemoveLines[i];
var actual = result[startLine + i];
if (!string.Equals(expected.TrimEnd(), actual.TrimEnd(), StringComparison.Ordinal))
return $"Error: Hunk at line {hunk.OriginalStart + i} mismatch. Expected: \"{Truncate(expected, 60)}\" Got: \"{Truncate(actual, 60)}\"";
}

// Remove old lines (validated above)
for (var i = 0; i < hunk.RemoveLines.Count; i++)
result.RemoveAt(startLine);

// Insert new lines
for (var i = hunk.AddLines.Count - 1; i >= 0; i--)
result.Insert(startLine, hunk.AddLines[i]);

offset += hunk.AddLines.Count - hunk.RemoveLines.Count;
}
Comment on lines +53 to +80
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

ApplyPatchTool applies hunks purely by line number and does not validate context or that the removed lines match the file contents. It also silently truncates removals via Math.Min(...), which can corrupt files while still reporting success. Consider implementing a real unified-diff apply (honor context lines, verify removals match, fail-fast on mismatches) or using a proven patch library.

Copilot uses AI. Check for mistakes.

var tmp = resolvedPath + ".tmp";
try
{
await File.WriteAllLinesAsync(tmp, result, ct);
File.Move(tmp, resolvedPath, overwrite: true);
}
catch
{
try { File.Delete(tmp); } catch { /* best-effort cleanup */ }
throw;
}

return $"Applied {hunks.Count} hunk(s) to {path}.";
}

private sealed record Hunk(int OriginalStart, List<string> RemoveLines, List<string> AddLines);

private static List<Hunk> ParseHunks(string patch)
{
var hunks = new List<Hunk>();
var lines = patch.Split('\n');
Hunk? current = null;

foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd('\r');

if (line.StartsWith("@@", StringComparison.Ordinal))
{
if (current is not null)
hunks.Add(current);

var origStart = ParseHunkStart(line);
current = new Hunk(origStart, [], []);
}
else if (current is not null)
{
if (line.StartsWith('-'))
current.RemoveLines.Add(line[1..]);
else if (line.StartsWith('+'))
current.AddLines.Add(line[1..]);
// Context lines (starting with space) are skipped — we trust line numbers
}
}

if (current is not null)
hunks.Add(current);

return hunks;
}

private static int ParseHunkStart(string header)
{
// Parse @@ -start,count +start,count @@
var idx = header.IndexOf('-', 3);
if (idx < 0) return 1;
var comma = header.IndexOf(',', idx);
var end = comma > 0 ? comma : header.IndexOf(' ', idx + 1);
if (end < 0) end = header.Length;
return int.TryParse(header.AsSpan(idx + 1, end - idx - 1), out var start) ? start : 1;
}

private static string Truncate(string s, int maxLen)
=> s.Length <= maxLen ? s : s[..maxLen] + "…";

private static string? GetString(System.Text.Json.JsonElement root, string property)
=> root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String
? el.GetString()
: null;
}
Loading
Loading