Werkzeugkasten: Zugangsdaten je Einheit (#692)

A unit holds credentials for its members, by tool and setting - needed once,
not once per agent, and other units never get them.

- UnitConfig.credentials, protected by ConfigSecrets like every secret.
- OrgChart.ToolSettingsFor: an agent's own settings, completed by the nearest
  unit up that holds the value; own non-empty values win; no tool is granted.
  Used for runs, tool jobs and preflight probes.
- OrgAdministration.SetUnitCredential, PUT /organisation/units/{id}/credentials;
  the view lists held settings, never values.
- Templates never carry unit credentials; they are named instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-01 12:50:27 +02:00
co-authored by Claude Opus 5.5
parent 661807b2d0
commit 9a265888f8
17 changed files with 306 additions and 13 deletions
@@ -74,6 +74,15 @@ public sealed class UnitConfig
/// <summary>#691: The template the unit came from — its texts and rules are someone else's.</summary>
[JsonPropertyName("templateOrigin")]
public string? TemplateOrigin { get; set; }
/// <summary>
/// #692: Credentials the unit holds for its members, by tool and setting — a purchasing
/// department with six agents needs the marketplace key once, not six times, and the
/// accounts department never gets it. Every value is a secret, stored protected; a member's
/// own non-empty value wins, and a credential grants no tool.
/// </summary>
[JsonPropertyName("credentials")]
public Dictionary<string, Dictionary<string, string>>? Credentials { get; set; }
}
/// <summary>
+6 -3
View File
@@ -1853,9 +1853,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
DelegationChain? chain = null, string? runId = null)
{
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
// #692: completed by the credentials the agent's unit holds for the tool.
var toolConfig = OrganisationProvider?.Invoke() is { } org
? org.ToolSettingsFor(agentConfig, toolName)
: agentConfig.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
var baseChain = chain ?? DelegationChain.Root(agentConfig.AgentId);
@@ -135,6 +135,43 @@ public sealed class OrgChart
: null;
}
/// <summary>
/// #692: The settings a tool of this agent runs with — its own, completed by the credentials
/// its unit and the units above hold for that tool (the nearest first). The agent's own
/// non-empty value wins; nothing is added for a tool the agent does not have.
/// </summary>
public IReadOnlyDictionary<string, object?> ToolSettingsFor(AgentConfig agent, string toolName)
{
if (!agent.Tools.TryGetValue(toolName, out var own))
return new Dictionary<string, object?>().AsReadOnly();
if (UnitOf(agent.AgentId) is not { } unit)
return own.AsReadOnly();
Dictionary<string, object?>? merged = null;
foreach (var holder in new[] { unit }.Concat(Ancestors(unit.Id)))
{
if (holder.Credentials?.FirstOrDefault(c => string.Equals(c.Key, toolName, StringComparison.OrdinalIgnoreCase)).Value is not { } held)
continue;
foreach (var (key, value) in held)
{
merged ??= new Dictionary<string, object?>(own, StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(value) || (merged.TryGetValue(key, out var current) && !IsEmpty(current)))
continue;
merged[key] = value;
}
}
return (merged ?? own).AsReadOnly();
}
private static bool IsEmpty(object? value) => value switch
{
null => true,
string s => s.Length == 0,
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } e => string.IsNullOrEmpty(e.GetString()),
System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined } => true,
_ => false
};
/// <summary>The unit this agent leads, if any.</summary>
public UnitConfig? LedBy(string agentId)
{
@@ -99,6 +99,9 @@ public sealed record CredentialNeed(
public static class OrgTemplates
{
/// <summary>A credential need held by a unit, not an agent: <c>unit:&lt;id&gt;</c>.</summary>
public const string UnitPrefix = "unit:";
/// <summary>Longest Identity or Soul text a template may bring — it goes into every prompt.</summary>
public const int MaxTextLength = 50_000;
@@ -121,7 +124,14 @@ public static class OrgTemplates
foreach (var unit in instance.Units)
{
template.Units.Add(JsonSerializer.Deserialize<UnitConfig>(JsonSerializer.Serialize(unit))!);
var copy = JsonSerializer.Deserialize<UnitConfig>(JsonSerializer.Serialize(unit))!;
// #692: what a unit holds for its members stays here — named, never carried.
foreach (var (tool, held) in copy.Credentials ?? [])
foreach (var key in held.Where(h => h.Value.Length > 0).Select(h => h.Key))
template.Credentials.Add(new CredentialNeed(UnitPrefix + unit.Id, tool, key));
copy.Credentials = null;
copy.TemplateOrigin = null;
template.Units.Add(copy);
if (doctrineOf(unit.Id) is { Length: > 0 } doctrine)
template.Doctrines[unit.Id] = doctrine;
}
@@ -225,6 +235,8 @@ public static class OrgTemplates
problems.Add($"Unit '{u.Id}' appears twice.");
if (string.IsNullOrWhiteSpace(u.Name))
problems.Add($"Unit '{u.Id}' has no name.");
if (u.Credentials?.Values.Any(h => h.Values.Any(v => v.Length > 0)) == true)
problems.Add($"Unit '{u.Id}' brings credentials — a template never carries one.");
}
foreach (var u in template.Units.Where(u => u.ParentId is not null && !units.Contains(u.ParentId)))
problems.Add($"Unit '{u.Id}' hangs under '{u.ParentId}', which the template does not have.");
@@ -63,6 +63,13 @@ public static class ConfigSecrets
foreach (var agent in config.Agents)
Apply(agent, transform);
// #692: everything a unit holds for its members is a secret, whatever its name.
foreach (var unit in config.Units)
foreach (var settings in unit.Credentials?.Values ?? Enumerable.Empty<Dictionary<string, string>>())
foreach (var key in settings.Keys.ToList())
if (settings[key] is { Length: > 0 } value)
settings[key] = transform(value) ?? "";
}
// ─── Agent ───
@@ -159,9 +159,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
var agentId = TaskAssignee.AgentId(task.Assignee);
var config = _agents().FirstOrDefault(a => a.AgentId == agentId);
var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg)
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
// #692: completed by the credentials the agent's unit holds for the tool.
var toolConfig = config is null
? new Dictionary<string, object?>().AsReadOnly()
: _engine.OrganisationProvider?.Invoke() is { } org
? org.ToolSettingsFor(config, task.ToolName)
: config.Tools.TryGetValue(task.ToolName, out var cfg)
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var result = await provider.ExecuteJobAsync(
task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);