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:
co-authored by
Claude Opus 5.5
parent
661807b2d0
commit
9a265888f8
@@ -796,9 +796,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
if (agent is null)
|
||||
return null;
|
||||
|
||||
var toolConfig = agent.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
var toolConfig = Core.Organisation.OrgChart.From(instance).ToolSettingsFor(agent, toolName);
|
||||
|
||||
return new AgentToolContext(
|
||||
agent.AgentId,
|
||||
|
||||
@@ -271,6 +271,44 @@ public sealed partial class OrgAdministration(AppHost host, ILogger logger)
|
||||
return AdminResult.Success(trimmed.Count == 0 ? message : message + $" Lowered with it: {string.Join(", ", trimmed)}.");
|
||||
}
|
||||
|
||||
// ─── credentials per unit (#692) ───
|
||||
|
||||
/// <summary>
|
||||
/// #692: Sets or removes one credential a unit holds for its members' tool. The value is
|
||||
/// written protected and never shown again — the audit names the setting, not the value.
|
||||
/// </summary>
|
||||
public AdminResult SetUnitCredential(string unitId, string tool, string key, string? value, string reason = "")
|
||||
{
|
||||
if (FindUnit(unitId) is not { } unit)
|
||||
return AdminResult.Failure($"There is no unit '{unitId}'.");
|
||||
tool = (tool ?? "").Trim();
|
||||
key = (key ?? "").Trim();
|
||||
if (tool.Length == 0 || key.Length == 0)
|
||||
return AdminResult.Failure("Name the tool and the setting the credential is for.");
|
||||
|
||||
unit.Credentials ??= new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
var toolKey = unit.Credentials.Keys.FirstOrDefault(k => Same(k, tool)) ?? tool;
|
||||
if (!unit.Credentials.TryGetValue(toolKey, out var held))
|
||||
unit.Credentials[toolKey] = held = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var remove = string.IsNullOrEmpty(value);
|
||||
if (remove)
|
||||
{
|
||||
held.Remove(held.Keys.FirstOrDefault(k => Same(k, key)) ?? key);
|
||||
if (held.Count == 0)
|
||||
unit.Credentials.Remove(toolKey);
|
||||
if (unit.Credentials.Count == 0)
|
||||
unit.Credentials = null;
|
||||
}
|
||||
else
|
||||
held[held.Keys.FirstOrDefault(k => Same(k, key)) ?? key] = value!;
|
||||
|
||||
Save(null, "unit_credential", new { unitId, tool = toolKey, key, set = !remove }, reason);
|
||||
return AdminResult.Success(remove
|
||||
? $"'{unit.Name}' no longer holds {toolKey}.{key}."
|
||||
: $"'{unit.Name}' holds {toolKey}.{key} for its members and the units below — where an agent has {toolKey} and no value of its own.");
|
||||
}
|
||||
|
||||
// ─── votes (#702) ───
|
||||
|
||||
/// <summary>
|
||||
@@ -1004,7 +1042,7 @@ public sealed partial class OrgAdministration(AppHost host, ILogger logger)
|
||||
{
|
||||
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
|
||||
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults, Approvals = u.Approvals,
|
||||
Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin
|
||||
Inspection = u.Inspection, Votes = u.Votes, TemplateOrigin = u.TemplateOrigin, Credentials = u.Credentials
|
||||
};
|
||||
|
||||
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:<id></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);
|
||||
|
||||
@@ -100,6 +100,14 @@
|
||||
"org.approvalsOperations": "Operationen",
|
||||
"org.approvalsPerDay": "Höchstens je Tag",
|
||||
"org.approvalsHours": "Stunden je Freigabe",
|
||||
"org.credentials": "Zugangsdaten der Einheit",
|
||||
"org.credentialsHint": "Zugangsdaten, die die Einheit für ihre Mitglieder und die Einheiten darunter hält — einmal gebraucht, nicht einmal je Agent, und andere Einheiten bekommen sie nie. Ein Mitglied nutzt sie nur für ein Werkzeug, das es hat, und nur, wo es keinen eigenen Wert trägt. Werte werden geschützt gespeichert und nie wieder angezeigt.",
|
||||
"org.credentialsNone": "Die Einheit hält keine Zugangsdaten.",
|
||||
"org.credentialSet": "gesetzt",
|
||||
"org.credentialRemove": "Entfernen",
|
||||
"org.credentialTool": "Werkzeug",
|
||||
"org.credentialKey": "Einstellung",
|
||||
"org.credentialValue": "Wert",
|
||||
"org.doctrine": "Doktrin",
|
||||
"org.doctrineHint": "Was in dieser Einheit gerade gilt — Rahmen für jede Aufgabe ihrer Mitglieder, von ihrer Leitung ohne Rückfrage gesetzt. Sie erreicht nur die Mitglieder dieser Einheit, nicht die Untereinheiten; die haben ihre eigene. Kurz halten: Sie geht in jeden Lauf. Leer entfernt sie.",
|
||||
"org.doctrinePlaceholder": "Jeder Lieferantenkontakt wird in der Datenbank erfasst, bevor er weitergegeben wird.",
|
||||
|
||||
@@ -100,6 +100,14 @@
|
||||
"org.approvalsOperations": "Operations",
|
||||
"org.approvalsPerDay": "Per day at most",
|
||||
"org.approvalsHours": "Hours per approval",
|
||||
"org.credentials": "Credentials of the unit",
|
||||
"org.credentialsHint": "Credentials the unit holds for its members and the units below — needed once, not once per agent, and other units never get them. A member uses them only for a tool it has, and only where it has no value of its own. Values are stored protected and never shown again.",
|
||||
"org.credentialsNone": "The unit holds no credentials.",
|
||||
"org.credentialSet": "set",
|
||||
"org.credentialRemove": "Remove",
|
||||
"org.credentialTool": "Tool",
|
||||
"org.credentialKey": "Setting",
|
||||
"org.credentialValue": "Value",
|
||||
"org.doctrine": "Doctrine",
|
||||
"org.doctrineHint": "What holds in this unit now — a frame for every task of its members, set by its lead without asking. It reaches this unit's members only, not the sub-units; they have their own. Short: it goes into every run. Empty removes it.",
|
||||
"org.doctrinePlaceholder": "Every supplier contact is entered in the database before it is passed on.",
|
||||
|
||||
@@ -482,6 +482,7 @@ function unitPanel(unit) {
|
||||
|
||||
cards.push(unitWorkspaceCard(unit));
|
||||
cards.push(ceilingCard(unit));
|
||||
cards.push(credentialsCard(unit));
|
||||
cards.push(approvalsCard(unit));
|
||||
cards.push(doctrineCard(unit));
|
||||
cards.push(inspectionCard(unit));
|
||||
@@ -552,6 +553,43 @@ function ceilingCard(unit) {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* #692: Credentials the unit holds for its members — one marketplace key for six agents, and
|
||||
* the accounts department never gets it. Values are written, never shown: only which settings
|
||||
* are held is listed.
|
||||
*/
|
||||
function credentialsCard(unit) {
|
||||
const held = unit.credentialKeys ?? {};
|
||||
const tool = erstelleElement("input", { type: "text", placeholder: "DirectAPI" });
|
||||
const key = erstelleElement("input", { type: "text", placeholder: "apiKey" });
|
||||
const value = erstelleElement("input", { type: "password", autocomplete: "new-password" });
|
||||
const path = `/organisation/units/${encodeURIComponent(unit.id)}/credentials`;
|
||||
|
||||
const rows = Object.entries(held).flatMap(([t1, keys]) => keys.map((k) => erstelleElement("li", { klasse: "gruppe-horizontal text-klein" },
|
||||
erstelleElement("span", { text: `${t1}.${k}` }),
|
||||
erstellePlakette(t("org.credentialSet"), "erfolg"),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: t("org.credentialRemove"),
|
||||
onclick: () => send(path, "PUT", { tool: t1, key: k, value: null, reason: reason() })
|
||||
}))));
|
||||
|
||||
return erstelleKarte(t("org.credentials"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.credentialsHint") }),
|
||||
rows.length ? erstelleElement("ul", { klasse: "org-liste" }, ...rows) : erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.credentialsNone") }),
|
||||
erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("org.credentialTool") }), tool,
|
||||
erstelleElement("label", { text: t("org.credentialKey") }), key,
|
||||
erstelleElement("label", { text: t("org.credentialValue") }), value),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "klein", text: t("common.save"),
|
||||
onclick: async () => {
|
||||
const ok = await send(path, "PUT", { tool: tool.value.trim(), key: key.value.trim(), value: value.value, reason: reason() });
|
||||
if (ok) value.value = "";
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* #687: What the unit's lead may approve instead of the human. One operation per line —
|
||||
* "Tool.operation", or "Tool" for all of it. Changes to agents are refused by the server.
|
||||
|
||||
@@ -24,7 +24,9 @@ public sealed record UnitDto(
|
||||
// #693: the lead's spot checks; null: none.
|
||||
UnitInspectionDto? Inspection = null,
|
||||
// #702: what the unit puts to a blind vote; null: nothing.
|
||||
UnitVotesDto? Votes = null);
|
||||
UnitVotesDto? Votes = null,
|
||||
// #692: the credentials the unit holds, by tool — names only, never values.
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>>? CredentialKeys = null);
|
||||
|
||||
/// <summary>#698: One version of a unit's doctrine.</summary>
|
||||
public sealed record DoctrineDto(int Version, string Text, string SetBy, DateTime SetAt, string? RunId, string Reason);
|
||||
@@ -42,6 +44,9 @@ public sealed record InspectionInputDto(bool Enabled, double? Rate, double? Dens
|
||||
|
||||
public sealed record FindingDto(string Kind, string Text);
|
||||
|
||||
/// <summary>#692: One credential a unit holds; an empty value removes it.</summary>
|
||||
public sealed record UnitCredentialInputDto(string Tool, string Key, string? Value, string Reason = "");
|
||||
|
||||
/// <summary>#691: A template to preview or import, with what the human changes on the way in.</summary>
|
||||
public sealed record TemplateImportDto(
|
||||
System.Text.Json.JsonElement Template,
|
||||
|
||||
@@ -30,7 +30,10 @@ internal static class OrganisationEndpoints
|
||||
chart.PathOf(u.Id), chart.Depth(u.Id),
|
||||
u.Approvals is { } ap ? new UnitApprovalsDto(ap.Operations, ap.PerDay, ap.Hours) : null,
|
||||
u.Inspection is { } ins ? new UnitInspectionDto(ins.Rate, ins.DenseRate, ins.NewDays, ins.PerDay) : null,
|
||||
u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null))
|
||||
u.Votes is { } v ? new UnitVotesDto(v.Operations, v.Voters, v.ApproveAt, v.RejectAt, v.EffectiveQuorum, v.Hours, v.PerDay) : null,
|
||||
u.Credentials is { Count: > 0 } held
|
||||
? held.ToDictionary(h => h.Key, h => (IReadOnlyList<string>)h.Value.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList())
|
||||
: null))
|
||||
.ToList();
|
||||
|
||||
var agents = host.Instance.Agents
|
||||
@@ -105,6 +108,10 @@ internal static class OrganisationEndpoints
|
||||
return Results.Ok(new OutcomeDto(true, set.IsEmpty ? $"Doctrine of '{unit.Name}' removed." : $"Doctrine of '{unit.Name}' set (version {set.Version})."));
|
||||
});
|
||||
|
||||
// #692: Credentials a unit holds for its members — written here, never read back.
|
||||
org.MapPut("/units/{unitId}/credentials", (AppHost host, string unitId, UnitCredentialInputDto input) =>
|
||||
host.Organisation.SetUnitCredential(unitId, input.Tool, input.Key, input.Value, input.Reason).ToResponse());
|
||||
|
||||
// #691: The organisation as a template file — exported without credentials, imported only after a preview and a confirmation.
|
||||
org.MapGet("/template", (AppHost host, string? name, string? description) =>
|
||||
Results.Text(System.Text.Json.JsonSerializer.Serialize(
|
||||
|
||||
Reference in New Issue
Block a user