Skip to content

Commit a26f762

Browse files
committed
v0.5.7: multi-instance hands + 8 critical fixes
## Headline feature - Multi-instance Hands via optional instance_name (customer ask + #878). Web UI, CLI (--name / -n), API, kernel, registry all threaded. Two clip-youtube + clip-tiktok instances now coexist. Backward compatible when instance_name is omitted. ## Critical bug fixes - #919 [SECURITY] rm bypass closed. process_start tool now validates against exec_policy allowlist and rejects shell metacharacters in both command and args. Added 5 regression tests. - #1013 session_repair phase ordering — dedup now runs BEFORE synthetic result insertion, fixing Moonshot's non-unique tool_call_id format (function_name:index). Added regression test. - #1003 global [[fallback_providers]] now actually used at runtime. resolve_driver wraps primary in FallbackDriver with global fallback chain. Network errors escalate to fallback instead of infinite retry. - #937 Discord gateway heartbeat. Spawns interval task, tracks sequence, handles ACKs, detects zombie connections, force-closes on missing ACK. Credits @hello-world-bfree (PR #938) for the diagnosis. - #935 System prompt leak in Web UI. get_agent_session now filters Role::System by default (?include_system=true for debug). Defense in depth client-side filter too. - #984 Custom hands persistence. install_from_path copies to ~/.openfang/hands/. Kernel loads them on startup. - #884 Workspace version bump 0.5.5 -> 0.5.7. Binaries now correctly report --version as 0.5.7 instead of stale 0.5.5. ## Cleanup - rmcp 1.3 builder API adopted (credits @jefflower PR #986) for StreamableHttpClientTransportConfig. Drops unused Arc import. ## Stats - 22 files changed, all workspace tests passing (1800+) - Live-tested with daemon: v0.5.7 reported, multi-instance hands verified end-to-end, Groq round-trip PONG confirmed
1 parent 0796377 commit a26f762

22 files changed

Lines changed: 1315 additions & 154 deletions

File tree

Cargo.lock

Lines changed: 15 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ members = [
1818
]
1919

2020
[workspace.package]
21-
version = "0.5.5"
21+
version = "0.5.7"
2222
edition = "2021"
2323
license = "Apache-2.0 OR MIT"
2424
repository = "https://github.com/RightNow-AI/openfang"

crates/openfang-api/src/routes.rs

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -436,9 +436,16 @@ pub async fn send_message(
436436
}
437437

438438
/// GET /api/agents/:id/session — Get agent session (conversation history).
439+
///
440+
/// Query parameters:
441+
/// - `include_system` — when `true`, system-role messages are included in the
442+
/// response (intended for debugging only). Defaults to `false` so the
443+
/// internal system prompt is never leaked into the Web UI conversation
444+
/// history (issue #935).
439445
pub async fn get_agent_session(
440446
State(state): State<Arc<AppState>>,
441447
Path(id): Path<String>,
448+
Query(params): Query<HashMap<String, String>>,
442449
) -> impl IntoResponse {
443450
let agent_id: AgentId = match id.parse() {
444451
Ok(id) => id,
@@ -450,6 +457,14 @@ pub async fn get_agent_session(
450457
}
451458
};
452459

460+
// SECURITY (#935): Default to filtering out system-role messages so the
461+
// internal system prompt is never exposed in the Web UI conversation
462+
// history. Callers can opt-in via `?include_system=true` for debugging.
463+
let include_system = params
464+
.get("include_system")
465+
.map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "TRUE" | "True"))
466+
.unwrap_or(false);
467+
453468
let entry = match state.kernel.registry.get(agent_id) {
454469
Some(e) => e,
455470
None => {
@@ -462,6 +477,18 @@ pub async fn get_agent_session(
462477

463478
match state.kernel.memory.get_session(entry.session_id) {
464479
Ok(Some(session)) => {
480+
// Filter out system-role messages BEFORE any rendering / truncation
481+
// logic so the system prompt cannot leak into the response. The
482+
// raw message count is preserved separately for the API consumer.
483+
let raw_message_count = session.messages.len();
484+
let filtered_messages: Vec<&openfang_types::message::Message> = session
485+
.messages
486+
.iter()
487+
.filter(|m| {
488+
include_system || m.role != openfang_types::message::Role::System
489+
})
490+
.collect();
491+
465492
// Two-pass approach: ToolUse blocks live in Assistant messages while
466493
// ToolResult blocks arrive in subsequent User messages. Pass 1
467494
// collects all tool_use entries keyed by id; pass 2 attaches results.
@@ -472,7 +499,8 @@ pub async fn get_agent_session(
472499
let mut tool_use_index: std::collections::HashMap<String, (usize, usize)> =
473500
std::collections::HashMap::new();
474501

475-
for m in &session.messages {
502+
for m in &filtered_messages {
503+
let m = *m;
476504
let mut tools: Vec<serde_json::Value> = Vec::new();
477505
let mut msg_images: Vec<serde_json::Value> = Vec::new();
478506
let content = match &m.content {
@@ -562,8 +590,8 @@ pub async fn get_agent_session(
562590
built_messages.push(msg);
563591
}
564592

565-
// Pass 2: walk messages again and attach ToolResult to the correct tool
566-
for m in &session.messages {
593+
// Pass 2: walk filtered messages again and attach ToolResult to the correct tool
594+
for m in &filtered_messages {
567595
if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content {
568596
for b in blocks {
569597
if let openfang_types::message::ContentBlock::ToolResult {
@@ -593,12 +621,16 @@ pub async fn get_agent_session(
593621
}
594622

595623
let messages = built_messages;
624+
// `message_count` reflects what the API actually returns (system
625+
// messages excluded by default). `raw_message_count` is exposed
626+
// for callers that need to know the underlying total.
596627
(
597628
StatusCode::OK,
598629
Json(serde_json::json!({
599630
"session_id": session.id.0.to_string(),
600631
"agent_id": session.agent_id.0.to_string(),
601-
"message_count": session.messages.len(),
632+
"message_count": messages.len(),
633+
"raw_message_count": raw_message_count,
602634
"context_window_tokens": session.context_window_tokens,
603635
"label": session.label,
604636
"messages": messages,
@@ -4038,12 +4070,18 @@ pub async fn list_active_hands(State(state): State<Arc<AppState>>) -> impl IntoR
40384070
let items: Vec<serde_json::Value> = instances
40394071
.iter()
40404072
.map(|i| {
4073+
// Effective agent name: custom instance_name takes priority, otherwise HAND.toml default.
4074+
let effective_agent_name = i
4075+
.instance_name
4076+
.clone()
4077+
.unwrap_or_else(|| i.agent_name.clone());
40414078
serde_json::json!({
40424079
"instance_id": i.instance_id,
40434080
"hand_id": i.hand_id,
4081+
"instance_name": i.instance_name,
40444082
"status": format!("{}", i.status),
40454083
"agent_id": i.agent_id.map(|a| a.to_string()),
4046-
"agent_name": i.agent_name,
4084+
"agent_name": effective_agent_name,
40474085
"activated_at": i.activated_at.to_rfc3339(),
40484086
"updated_at": i.updated_at.to_rfc3339(),
40494087
})
@@ -4507,9 +4545,12 @@ pub async fn activate_hand(
45074545
Path(hand_id): Path<String>,
45084546
body: Option<Json<openfang_hands::ActivateHandRequest>>,
45094547
) -> impl IntoResponse {
4510-
let config = body.map(|b| b.0.config).unwrap_or_default();
4548+
let (config, instance_name) = match body.map(|b| b.0) {
4549+
Some(r) => (r.config, r.instance_name),
4550+
None => (std::collections::HashMap::new(), None),
4551+
};
45114552

4512-
match state.kernel.activate_hand(&hand_id, config) {
4553+
match state.kernel.activate_hand(&hand_id, config, instance_name) {
45134554
Ok(instance) => {
45144555
// If the hand agent has a non-reactive schedule (autonomous hands),
45154556
// start its background loop so it begins running immediately.
@@ -4533,14 +4574,19 @@ pub async fn activate_hand(
45334574
}
45344575
}
45354576
}
4577+
let effective_agent_name = instance
4578+
.instance_name
4579+
.clone()
4580+
.unwrap_or_else(|| instance.agent_name.clone());
45364581
(
45374582
StatusCode::OK,
45384583
Json(serde_json::json!({
45394584
"instance_id": instance.instance_id,
45404585
"hand_id": instance.hand_id,
4586+
"instance_name": instance.instance_name,
45414587
"status": format!("{}", instance.status),
45424588
"agent_id": instance.agent_id.map(|a| a.to_string()),
4543-
"agent_name": instance.agent_name,
4589+
"agent_name": effective_agent_name,
45444590
"activated_at": instance.activated_at.to_rfc3339(),
45454591
})),
45464592
)

crates/openfang-api/static/index_body.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2892,6 +2892,14 @@ <h3><span x-text="detailHand.icon"></span> <span x-text="detailHand.name"></span
28922892

28932893
<!-- ═══ Step 2: Configure ═══ -->
28942894
<div class="hand-wizard-body" x-show="setupStep === 2">
2895+
<!-- Optional instance name (for running multiple instances of the same hand) -->
2896+
<div class="mb-4">
2897+
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px;text-transform:uppercase">
2898+
Instance name <span style="text-transform:none;letter-spacing:0;color:#888">(optional)</span>
2899+
</div>
2900+
<div class="text-xs text-dim mb-2">Leave empty for a single instance of this hand. Set a name to run multiple instances in parallel.</div>
2901+
<input type="text" class="form-input" x-model="setupWizard.instanceName" placeholder="e.g. clip-youtube, lead-q4-outreach" style="width:100%">
2902+
</div>
28952903
<template x-if="!setupHasSettings">
28962904
<div class="text-sm text-dim" style="text-align:center;padding:20px 0">No configuration needed for this hand. Click Next to continue.</div>
28972905
</template>

crates/openfang-api/static/js/pages/chat.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,14 @@ function chatPage() {
519519
try {
520520
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
521521
if (data.messages && data.messages.length) {
522-
self.messages = data.messages.map(function(m) {
522+
// Defense-in-depth (#935): never render system-role messages in the
523+
// conversation history view, even if the backend somehow returns
524+
// one. The server already filters these out by default, but we
525+
// guard here too so a regression cannot leak the system prompt.
526+
var visible = data.messages.filter(function(m) {
527+
return m && m.role !== 'System' && m.role !== 'system';
528+
});
529+
self.messages = visible.map(function(m) {
523530
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
524531
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
525532
// Sanitize any raw function-call text from history

crates/openfang-api/static/js/pages/hands.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ function handsPage() {
122122
}
123123
}
124124
}
125+
// Initialize optional instance name (for multi-instance hands).
126+
data.instanceName = '';
125127
this.setupWizard = data;
126128
// Skip deps step if no requirements
127129
var hasReqs = data.requirements && data.requirements.length > 0;
@@ -408,8 +410,14 @@ function handsPage() {
408410
}
409411
this.activatingId = handId;
410412
try {
411-
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
412-
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
413+
var payload = { config: config };
414+
var name = (this.setupWizard.instanceName || '').trim();
415+
if (name) {
416+
payload.instance_name = name;
417+
}
418+
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', payload);
419+
var label = data.instance_name || data.agent_name || data.instance_id;
420+
this.showToast('Hand "' + handId + '" activated as ' + label);
413421
this.closeSetupWizard();
414422
await this.loadActive();
415423
this.tab = 'active';

0 commit comments

Comments
 (0)