feat: add SidecarManager actor pattern, SPKI pinning, btmsg seen_messages, Aider autonomous mode

Tribunal priorities 1-4: SidecarManager refactored to mpsc actor thread
(eliminates TOCTOU race), SPKI TOFU certificate pinning for relay TLS,
per-message btmsg acknowledgment via seen_messages table, Aider
autonomous mode toggle gating shell execution.
This commit is contained in:
Hibryda 2026-03-14 04:39:40 +01:00
parent 949d90887d
commit 23b4d0cf26
22 changed files with 1273 additions and 297 deletions

View file

@ -169,6 +169,29 @@ export async function registerAgents(config: import('../types/groups').GroupsFil
return invoke('btmsg_register_agents', { config });
}
// ---- Per-message acknowledgment (seen_messages) ----
/**
* Get messages not yet seen by this session (per-session tracking).
*/
export async function getUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
return invoke('btmsg_unseen_messages', { agentId, sessionId });
}
/**
* Mark specific message IDs as seen by this session.
*/
export async function markMessagesSeen(sessionId: string, messageIds: string[]): Promise<void> {
return invoke('btmsg_mark_seen', { sessionId, messageIds });
}
/**
* Prune old seen_messages entries (7-day default, emergency 3-day at 200k rows).
*/
export async function pruneSeen(): Promise<number> {
return invoke('btmsg_prune_seen');
}
// ---- Heartbeat monitoring ----
/**

View file

@ -8,6 +8,8 @@ export interface RemoteMachineConfig {
url: string;
token: string;
auto_connect: boolean;
/** SPKI SHA-256 pin(s) for certificate verification. Empty = TOFU on first connect. */
spki_pins?: string[];
}
export interface RemoteMachineInfo {
@ -16,6 +18,8 @@ export interface RemoteMachineInfo {
url: string;
status: string;
auto_connect: boolean;
/** Currently stored SPKI pin hashes (hex-encoded SHA-256) */
spki_pins: string[];
}
// --- Machine management ---
@ -40,6 +44,23 @@ export async function disconnectRemoteMachine(machineId: string): Promise<void>
return invoke('remote_disconnect', { machineId });
}
// --- SPKI certificate pinning ---
/** Probe a relay server's TLS certificate and return its SHA-256 hash (hex-encoded). */
export async function probeSpki(url: string): Promise<string> {
return invoke('remote_probe_spki', { url });
}
/** Add an SPKI pin hash to a machine's trusted pins. */
export async function addSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_add_pin', { machineId, pin });
}
/** Remove an SPKI pin hash from a machine's trusted pins. */
export async function removeSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_remove_pin', { machineId, pin });
}
// --- Remote event listeners ---
export interface RemoteSidecarMessage {
@ -141,3 +162,19 @@ export async function onRemoteMachineReconnectReady(
callback(event.payload);
});
}
// --- SPKI TOFU event ---
export interface RemoteSpkiTofuEvent {
machineId: string;
hash: string;
}
/** Listen for TOFU (Trust On First Use) events when a new SPKI pin is auto-stored. */
export async function onRemoteSpkiTofu(
callback: (msg: RemoteSpkiTofuEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteSpkiTofuEvent>('remote-spki-tofu', (event) => {
callback(event.payload);
});
}

View file

@ -62,6 +62,8 @@
model?: string;
/** Extra env vars injected into agent process (e.g. BTMSG_AGENT_ID) */
extraEnv?: Record<string, string>;
/** Shell execution mode for AI agents. 'restricted' blocks auto-exec; 'autonomous' allows it. */
autonomousMode?: 'restricted' | 'autonomous';
/** Auto-triggered prompt (e.g. periodic context refresh). Picked up when agent is idle. */
autoPrompt?: string;
/** Called when autoPrompt has been consumed */
@ -69,7 +71,7 @@
onExit?: () => void;
}
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, model: modelOverride, extraEnv, autoPrompt, onautopromptconsumed, onExit }: Props = $props();
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, model: modelOverride, extraEnv, autonomousMode, autoPrompt, onautopromptconsumed, onExit }: Props = $props();
let session = $derived(getAgentSession(sessionId));
let inputPrompt = $state(initialPrompt);
@ -213,6 +215,7 @@
system_prompt: systemPrompt,
model: modelOverride || undefined,
worktree_name: useWorktrees ? sessionId : undefined,
provider_config: { autonomousMode: autonomousMode ?? 'restricted' },
extra_env: extraEnv,
});
inputPrompt = '';

View file

@ -27,7 +27,7 @@
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
import { getSecret } from '../../adapters/secrets-bridge';
import { getUnreadCount } from '../../adapters/btmsg-bridge';
import { getUnseenMessages, markMessagesSeen } from '../../adapters/btmsg-bridge';
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
import { SessionId, ProjectId } from '../../types/ids';
import AgentPane from '../Agent/AgentPane.svelte';
@ -161,26 +161,35 @@ bttask comment <task-id> "update" # Add a comment
stopAgent(sessionId).catch(() => {});
});
// btmsg inbox polling — auto-wake agent when it receives messages from other agents
// btmsg inbox polling — per-message acknowledgment wake mechanism
// Uses seen_messages table for per-session tracking instead of global unread count.
// Every unseen message triggers exactly one wake, regardless of timing.
let msgPollTimer: ReturnType<typeof setInterval> | null = null;
let lastKnownUnread = 0;
function startMsgPoll() {
if (msgPollTimer) clearInterval(msgPollTimer);
msgPollTimer = setInterval(async () => {
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
try {
const count = await getUnreadCount(project.id as unknown as AgentId);
if (count > 0 && count > lastKnownUnread) {
lastKnownUnread = count;
contextRefreshPrompt = `[New Message] You have ${count} unread message(s). Check your inbox with \`btmsg inbox\` and respond appropriately.`;
const unseen = await getUnseenMessages(
project.id as unknown as AgentId,
sessionId,
);
if (unseen.length > 0) {
// Build a prompt with the actual message contents
const msgSummary = unseen.map(m =>
`From ${m.senderName ?? m.fromAgent} (${m.senderRole ?? 'unknown'}): ${m.content}`
).join('\n');
contextRefreshPrompt = `[New Messages] You have ${unseen.length} unread message(s):\n\n${msgSummary}\n\nRespond appropriately using \`btmsg send <agent-id> "reply"\`.`;
// Mark as seen immediately to prevent re-injection
await markMessagesSeen(sessionId, unseen.map(m => m.id));
logAuditEvent(
project.id as unknown as AgentId,
'wake_event',
`Agent woken by ${count} unread btmsg message(s)`,
`Agent woken by ${unseen.length} btmsg message(s)`,
).catch(() => {});
} else if (count === 0) {
lastKnownUnread = 0;
}
} catch {
// btmsg not available, ignore
@ -345,6 +354,7 @@ bttask comment <task-id> "update" # Add a comment
agentSystemPrompt={agentPrompt}
model={project.model}
extraEnv={agentEnv}
autonomousMode={project.autonomousMode}
autoPrompt={contextRefreshPrompt}
onautopromptconsumed={handleAutoPromptConsumed}
onExit={handleNewSession}

View file

@ -1150,6 +1150,27 @@
{/if}
{/if}
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Shell Execution
</span>
<div class="wake-strategy-row">
<button
class="strategy-btn"
class:active={!agent.autonomousMode || agent.autonomousMode === 'restricted'}
title="Shell commands are shown but not auto-executed"
onclick={() => updateAgent(activeGroupId, agent.id, { autonomousMode: 'restricted' })}
>Restricted</button>
<button
class="strategy-btn"
class:active={agent.autonomousMode === 'autonomous'}
title="Shell commands are auto-executed with audit logging"
onclick={() => updateAgent(activeGroupId, agent.id, { autonomousMode: 'autonomous' })}
>Autonomous</button>
</div>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@ -1440,6 +1461,27 @@
</label>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Shell Execution
</span>
<div class="wake-strategy-row">
<button
class="strategy-btn"
class:active={!project.autonomousMode || project.autonomousMode === 'restricted'}
title="Shell commands are shown but not auto-executed"
onclick={() => updateProject(activeGroupId, project.id, { autonomousMode: 'restricted' })}
>Restricted</button>
<button
class="strategy-btn"
class:active={project.autonomousMode === 'autonomous'}
title="Shell commands are auto-executed with audit logging"
onclick={() => updateProject(activeGroupId, project.id, { autonomousMode: 'autonomous' })}
>Autonomous</button>
</div>
</div>
<div class="card-field">
<span class="card-field-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>

View file

@ -20,6 +20,8 @@ export interface ProjectConfig {
useWorktrees?: boolean;
/** When true, sidecar process is sandboxed via Landlock (Linux 5.13+, restricts filesystem access) */
sandboxEnabled?: boolean;
/** Shell execution mode for AI agents. 'restricted' (default) surfaces commands for approval; 'autonomous' auto-executes with audit logging */
autonomousMode?: 'restricted' | 'autonomous';
/** Anchor token budget scale (defaults to 'medium' = 6K tokens) */
anchorBudgetScale?: AnchorBudgetScale;
/** Stall detection threshold in minutes (defaults to 15) */
@ -56,6 +58,7 @@ export function agentToProject(agent: GroupAgentConfig, groupCwd: string): Proje
isAgent: true,
agentRole: agent.role,
systemPrompt: agent.systemPrompt,
autonomousMode: agent.autonomousMode,
};
}
@ -83,6 +86,8 @@ export interface GroupAgentConfig {
wakeStrategy?: WakeStrategy;
/** Wake threshold 0..1 for smart strategy (default 0.5) */
wakeThreshold?: number;
/** Shell execution mode. 'restricted' (default) surfaces commands for approval; 'autonomous' auto-executes with audit logging */
autonomousMode?: 'restricted' | 'autonomous';
}
export interface GroupConfig {