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:
parent
949d90887d
commit
23b4d0cf26
22 changed files with 1273 additions and 297 deletions
|
|
@ -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 ----
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue