Skip to content

Commit d9324ef

Browse files
Pyinerclaude
andcommitted
fix(dispatch): review findings — dispatch outcome, secrets boundary, target strictness
- AgentDispatchOutcome threads Started/QueuedToActiveRun{effective_run_id, pending_input_id} from the bridge through AgentDispatcher and InboundResult. A message queued into an already-active run now aborts the fresh-run response subscription instead of leaking it (internal dispatch and chat API), cron bookkeeping records the effective run id, and the pending input carries allowlisted attribution metadata (source/automation_id/origin_run_id) into the acknowledged user record. - Runtime-only run metadata (garyx_mcp_auth_token, remote_mcp_servers) is stripped at the bridge persistence chokepoint so provider wiring and MCP env secrets never reach transcript records. - An explicit cron target (thread-like or channel) whose thread record is missing now fails instead of silently succeeding through the bare-run bypass; only the thread-less cron::<job id> pseudo-target keeps it. - Thread-bound automations reject an explicit workspace_dir (the thread's workspace always applies) and the desktop dialog hides the agent picker and omits agent/workspace for thread-bound automations; docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e992e1 commit d9324ef

22 files changed

Lines changed: 299 additions & 54 deletions

File tree

desktop/garyx-desktop/src/main/garyx-client/automations.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export async function createRemoteAutomation(
159159
input: {
160160
label: string;
161161
prompt: string;
162-
agentId: string;
162+
agentId?: string;
163163
workspacePath?: string;
164164
targetThreadId?: string | null;
165165
schedule: DesktopAutomationSchedule;
@@ -174,7 +174,7 @@ export async function createRemoteAutomation(
174174
body: JSON.stringify({
175175
label: input.label,
176176
prompt: input.prompt,
177-
agentId: input.agentId,
177+
agentId: input.agentId || undefined,
178178
workspaceDir: input.workspacePath || undefined,
179179
targetThreadId: input.targetThreadId || undefined,
180180
schedule: input.schedule,

desktop/garyx-desktop/src/main/store.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,14 +1464,14 @@ export async function createDesktopAutomation(
14641464
targetThread?.workspacePath || null,
14651465
targetThreadId,
14661466
);
1467-
const explicitWorkspacePath = normalizeWorkspacePathInput(input.workspacePath);
1468-
const remoteWorkspacePath = targetThreadId
1469-
? explicitWorkspacePath || undefined
1470-
: workspacePath;
1467+
// A thread-bound automation always runs with the thread's own agent and
1468+
// workspace; the gateway rejects explicit overrides for that combination.
1469+
const remoteWorkspacePath = targetThreadId ? undefined : workspacePath;
1470+
const agentId = targetThreadId ? undefined : input.agentId?.trim() || undefined;
14711471
const created = await createRemoteAutomation(current.settings, {
14721472
label: input.label.trim(),
14731473
prompt: input.prompt.trim(),
1474-
agentId: input.agentId.trim(),
1474+
agentId,
14751475
workspacePath: remoteWorkspacePath,
14761476
targetThreadId: targetThreadId || null,
14771477
schedule: input.schedule,
@@ -1487,7 +1487,7 @@ export async function createDesktopAutomation(
14871487
state,
14881488
automation: state.automations.find((entry) => entry.id === created.id) || {
14891489
...created,
1490-
agentId: input.agentId.trim(),
1490+
agentId: agentId || created.agentId,
14911491
workspacePath,
14921492
targetThreadId,
14931493
},
@@ -1526,14 +1526,13 @@ export async function updateDesktopAutomation(input: {
15261526
fallbackWorkspacePath,
15271527
targetThreadId,
15281528
);
1529-
const explicitWorkspacePath = normalizeWorkspacePathInput(input.workspacePath);
1530-
const remoteWorkspacePath = targetThreadId
1531-
? explicitWorkspacePath || undefined
1532-
: workspacePath;
1529+
// A thread-bound automation always runs with the thread's own agent and
1530+
// workspace; the gateway rejects explicit overrides for that combination.
1531+
const remoteWorkspacePath = targetThreadId ? undefined : workspacePath;
15331532
const updated = await updateRemoteAutomation(current.settings, input.automationId, {
15341533
label: input.label?.trim(),
15351534
prompt: input.prompt?.trim(),
1536-
agentId: input.agentId?.trim(),
1535+
agentId: targetThreadId ? undefined : input.agentId?.trim(),
15371536
workspacePath: remoteWorkspacePath,
15381537
targetThreadId: hasTargetThreadInput ? targetThreadId || null : undefined,
15391538
schedule: input.schedule,

desktop/garyx-desktop/src/renderer/src/app-shell/useAutomationController.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,15 @@ export function useAutomationController({
208208
setError('Automation prompt is required.');
209209
return;
210210
}
211-
if (!automationDialog.draft.agentId.trim()) {
212-
setError('Choose an agent for this automation.');
213-
return;
214-
}
215211
const targetThreadId = automationDialog.draft.targetMode === 'existing_thread'
216212
? automationDialog.draft.targetThreadId.trim()
217213
: '';
214+
// A thread-bound automation runs under the thread's own agent; the agent
215+
// picker only applies to generated-thread automations.
216+
if (!targetThreadId && !automationDialog.draft.agentId.trim()) {
217+
setError('Choose an agent for this automation.');
218+
return;
219+
}
218220
const workspacePath = automationDialog.draft.workspacePath.trim();
219221
if (automationDialog.draft.targetMode === 'existing_thread' && !targetThreadId) {
220222
setError('Choose the thread this automation should post into.');
@@ -250,7 +252,7 @@ export function useAutomationController({
250252
? await window.garyxDesktop.createAutomation({
251253
label,
252254
prompt,
253-
agentId: automationDialog.draft.agentId,
255+
agentId: targetThreadId ? undefined : automationDialog.draft.agentId,
254256
workspacePath: targetThreadId ? undefined : workspacePath || undefined,
255257
targetThreadId: targetThreadId || null,
256258
schedule: automationDialog.draft.schedule,
@@ -259,7 +261,7 @@ export function useAutomationController({
259261
automationId: automationDialog.automationId || '',
260262
label,
261263
prompt,
262-
agentId: automationDialog.draft.agentId,
264+
agentId: targetThreadId ? undefined : automationDialog.draft.agentId,
263265
workspacePath: targetThreadId ? undefined : workspacePath || undefined,
264266
targetThreadId: targetThreadId || null,
265267
schedule: automationDialog.draft.schedule,

desktop/garyx-desktop/src/renderer/src/components/AutomationDialog.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export function AutomationDialog({
358358
/>
359359
</Field>
360360

361+
{draft.targetMode === 'existing_thread' ? null : (
361362
<Field>
362363
<FieldLabel>{t('Agent')}</FieldLabel>
363364
<Select
@@ -392,6 +393,7 @@ export function AutomationDialog({
392393
</SelectContent>
393394
</Select>
394395
</Field>
396+
)}
395397

396398
<Field>
397399
<FieldLabel>{t('Run In')}</FieldLabel>
@@ -427,7 +429,7 @@ export function AutomationDialog({
427429
</ToggleGroup>
428430
<FieldDescription>
429431
{draft.targetMode === 'existing_thread'
430-
? t('Each run posts the prompt into the selected thread.')
432+
? t("Each run posts the prompt into the selected thread, handled by the thread's own agent and workspace.")
431433
: t('Each run creates a fresh automation thread in the selected directory.')}
432434
</FieldDescription>
433435
</Field>

desktop/garyx-desktop/src/shared/contracts/automation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export interface DesktopAutomationActivityFeed {
5555
export interface CreateAutomationInput {
5656
label: string;
5757
prompt: string;
58-
agentId: string;
58+
/// Omitted for thread-bound automations: the thread's own agent handles
59+
/// each run, so an automation-level agent choice does not apply.
60+
agentId?: string;
5961
workspacePath?: string;
6062
targetThreadId?: string | null;
6163
schedule: DesktopAutomationSchedule;

docs/configuration.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -466,9 +466,10 @@ garyx automation delete <automation-id>
466466
By default a scheduled automation creates a fresh automation thread for each
467467
run using `--workspace-dir`. Passing `--thread-id` instead binds the automation
468468
to an existing Garyx thread; each scheduled or manual run sends the configured
469-
prompt into that thread and keeps the transcript in one conversation. When an
470-
automation is bound to a thread, the thread's workspace is used unless an
471-
explicit workspace is provided.
469+
prompt into that thread exactly like a user message and keeps the transcript
470+
in one conversation. A thread-bound automation always uses the thread's own
471+
workspace — combining `--thread-id` with an explicit `--workspace-dir` is
472+
rejected.
472473

473474
Automation schedules can be represented as hourly intervals, daily or weekday
474475
cron-style runs, one-shot timestamps, or monthly day-of-month runs. The mobile

garyx-bridge/src/multi_provider.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::HashMap;
22
use std::sync::Arc;
33

44
use async_trait::async_trait;
5-
use garyx_models::provider::{AgentRunRequest, StreamEvent};
5+
use garyx_models::provider::{AgentDispatchOutcome, AgentRunRequest, StreamEvent};
66
use garyx_models::thread_logs::ThreadLogSink;
77
use garyx_models::CustomAgentProfile;
88
use garyx_router::{AgentDispatcher, ThreadHistoryRepository, ThreadStore};
@@ -230,7 +230,7 @@ impl AgentDispatcher for MultiProviderBridge {
230230
&self,
231231
request: AgentRunRequest,
232232
response_callback: Option<Arc<dyn Fn(StreamEvent) + Send + Sync>>,
233-
) -> Result<(), String> {
233+
) -> Result<AgentDispatchOutcome, String> {
234234
self.start_agent_run(request, response_callback)
235235
.await
236236
.map_err(|e| e.to_string())

garyx-bridge/src/multi_provider/persistence.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,11 @@ pub(super) struct PendingUserInput {
245245
pub queued_at: String,
246246
#[serde(default, skip_serializing_if = "Option::is_none")]
247247
pub origin_id: Option<String>,
248+
/// Attribution metadata carried from the originating dispatch (e.g.
249+
/// `source`/`automation_id` for scheduled turns queued into an active
250+
/// run) and merged into the acknowledged user record.
251+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
252+
pub metadata: HashMap<String, Value>,
248253
pub status: PendingUserInputStatus,
249254
}
250255

@@ -698,6 +703,13 @@ impl StreamingRunSnapshot {
698703
if let Some(origin_id) = pending_input.origin_id.as_deref() {
699704
metadata.insert("origin_id".to_owned(), Value::String(origin_id.to_owned()));
700705
}
706+
// Attribution carried from the originating dispatch; built-in queue
707+
// markers above win on conflict.
708+
for (key, value) in &pending_input.metadata {
709+
metadata
710+
.entry(key.clone())
711+
.or_insert_with(|| value.clone());
712+
}
701713
self.session_messages.push(ProviderMessage {
702714
role: ProviderMessageRole::User,
703715
content: pending_input.content.clone(),
@@ -1091,8 +1103,18 @@ fn build_run_record_drafts(
10911103
drafts
10921104
}
10931105

1106+
/// Run metadata that exists only to configure the provider runtime and must
1107+
/// never be persisted into transcript records: the gateway bearer token and
1108+
/// the managed MCP server definitions (whose `env` blocks carry secrets).
1109+
/// This is the single persistence chokepoint — every dispatch source (chat
1110+
/// API, internal dispatch, scheduled turns) funnels through it.
1111+
const RUNTIME_ONLY_METADATA_KEYS: &[&str] = &["garyx_mcp_auth_token", "remote_mcp_servers"];
1112+
10941113
fn run_message_metadata(run: &PersistedRun<'_>) -> HashMap<String, Value> {
10951114
let mut metadata = run.metadata.clone();
1115+
for key in RUNTIME_ONLY_METADATA_KEYS {
1116+
metadata.remove(*key);
1117+
}
10961118
match run
10971119
.sdk_session_id
10981120
.map(str::trim)

garyx-bridge/src/multi_provider/persistence/tests.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,54 @@ async fn test_save_thread_messages_overrides_stale_metadata_sdk_session_id() {
669669
assert_eq!(stored["sdk_session_id"], "new-session");
670670
}
671671

672+
#[tokio::test]
673+
async fn test_save_thread_messages_strips_runtime_only_metadata() {
674+
let store: Arc<dyn ThreadStore> = Arc::new(InMemoryThreadStore::new());
675+
let history = make_history(store.clone());
676+
let session_messages = vec![ProviderMessage::assistant_text("answer")];
677+
let mut metadata = HashMap::new();
678+
// Runtime-only provider wiring: the gateway bearer token and managed MCP
679+
// definitions (whose env can carry secrets) must never reach transcript
680+
// records. Synthetic placeholder values only.
681+
metadata.insert("garyx_mcp_auth_token".to_owned(), json!("${TOKEN}"));
682+
metadata.insert(
683+
"remote_mcp_servers".to_owned(),
684+
json!({ "example": { "command": "example-mcp", "env": { "API_KEY": "${SECRET}" } } }),
685+
);
686+
metadata.insert("source".to_owned(), json!("automation"));
687+
metadata.insert("run_id".to_owned(), json!("run-runtime-only"));
688+
689+
save_thread_messages(
690+
&store,
691+
&history,
692+
PersistedRun {
693+
thread_id: "thread::runtime-only-metadata",
694+
user_message: "scheduled prompt",
695+
user_timestamp: Some("2026-03-01T00:00:00Z"),
696+
user_images: &[],
697+
assistant_response: "answer",
698+
sdk_session_id: None,
699+
provider_key: "provider::runtime-only",
700+
provider_type: ProviderType::ClaudeCode,
701+
session_messages: &session_messages,
702+
metadata: &metadata,
703+
},
704+
)
705+
.await;
706+
707+
let messages = committed_content(&history, "thread::runtime-only-metadata").await;
708+
assert!(!messages.is_empty());
709+
for message in &messages {
710+
let raw = message.to_string();
711+
assert!(
712+
!raw.contains("garyx_mcp_auth_token") && !raw.contains("remote_mcp_servers"),
713+
"runtime-only metadata leaked into transcript record: {raw}"
714+
);
715+
}
716+
// Ordinary metadata still rides along.
717+
assert_eq!(messages[0]["metadata"]["source"], "automation");
718+
}
719+
672720
#[test]
673721
fn test_streaming_run_snapshot_splits_assistant_segments() {
674722
let mut snapshot = StreamingRunSnapshot::default();

0 commit comments

Comments
 (0)