Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/design-docs/working-memory-triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Findings from CodeRabbit review + bug reports. Tracking resolution before merge.
- [x] **R3 — Don't exclude participant-role facts yet** (`prompts/en/cortex_knowledge_synthesis.md.j2:21`)
Exclusion of "The user is the CEO" drops participant context with nowhere else to live until Phase 6 ships. **Fixed in this slice:** knowledge synthesis now preserves concise participant/user role facts when they affect future routing, authority, relationships, or interpretation.

- [ ] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`)
`task` from user input persisted verbatim; could capture secrets/PII. Truncate and scrub.
- [x] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`)
`task` from user input persisted verbatim; could capture secrets/PII. **Fixed in this slice:** worker-spawn working-memory text now uses shared secret redaction and char-safe truncation.

- [ ] **R5 — Dirty flag only bumps on merges** (`src/agent/cortex.rs:1958`)
Prunes and decays also change the memory set but don't trigger knowledge synthesis re-gen. Add `report.pruned > 0 || report.decayed > 0`. **Partial in PR #570:** prunes and merges now dirty synthesis; decay remains intentionally importance-only and needs a follow-up decision.
Expand All @@ -44,8 +44,8 @@ Findings from CodeRabbit review + bug reports. Tracking resolution before merge.
- [ ] **R12 — Silent error swallowing in inspect_prompt** (`src/api/channels.rs:649`)
`unwrap_or_default()` / `.ok()` hides DB/template errors. Log and propagate per coding guidelines.

- [ ] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`)
Full error text persisted; could contain sensitive internals. Emit redacted summary only.
- [x] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`)
Full error text persisted; could contain sensitive internals. **Fixed in this slice:** cron error working-memory text now uses the same redacted, bounded summary while tracing keeps the full error.

- [ ] **R14 — Timezone fallback drops valid `cron_timezone`** (`src/main.rs:2559`)
If `user_timezone` is present but unparseable, `cron_timezone` is never tried. Parse each independently.
Expand Down
63 changes: 57 additions & 6 deletions src/agent/channel_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ enum WorkerCompletionKind {
Failed,
}

const WORKING_MEMORY_TASK_MAX_CHARS: usize = 500;

#[derive(Debug, Clone)]
pub(crate) enum WorkerCompletionError {
Cancelled { reason: String },
Expand Down Expand Up @@ -99,6 +101,14 @@ pub(crate) fn map_worker_completion_result(
(result_text, notify, success)
}

fn sanitize_worker_memory_task(task: &str, tool_secret_pairs: &[(String, String)]) -> String {
crate::secrets::scrub::scrub_working_memory_text(
task,
tool_secret_pairs,
WORKING_MEMORY_TASK_MAX_CHARS,
)
}

/// Build the worker status text (time + system info) used in worker system prompts.
///
/// Centralises the `SystemInfo` + `TemporalContext` assembly so every worker
Expand Down Expand Up @@ -597,9 +607,9 @@ async fn spawn_worker_inner(
let sandbox_write_allowlist = state.deps.sandbox.prompt_write_allowlist();
// Collect tool secret names so the worker template can list available credentials.
let secrets_guard = rc.secrets.load();
let tool_secret_names = match (*secrets_guard).as_ref() {
Some(store) => store.tool_secret_names(),
None => Vec::new(),
let (tool_secret_names, tool_secret_pairs) = match (*secrets_guard).as_ref() {
Some(store) => (store.tool_secret_names(), store.tool_secret_pairs()),
None => (Vec::new(), Vec::new()),
};

let browser_config = (**rc.browser_config.load()).clone();
Expand Down Expand Up @@ -793,12 +803,13 @@ async fn spawn_worker_inner(
})
.ok();

let memory_task = sanitize_worker_memory_task(task, &tool_secret_pairs);
state
.deps
.working_memory
.emit(
crate::memory::WorkingMemoryEventType::WorkerSpawned,
format!("Worker spawned: {task}"),
format!("Worker spawned: {memory_task}"),
)
.channel(state.channel_id.to_string())
.importance(0.6)
Expand Down Expand Up @@ -872,6 +883,9 @@ async fn spawn_opencode_worker_inner(
let persist_directory = directory.clone();

let oc_secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone();
let oc_tool_secret_pairs = oc_secrets_store
.as_ref()
.map_or_else(Vec::new, |store| store.tool_secret_pairs());

// Build temporal/status context so OpenCode workers get the same system
// info (time, model, context window) as builtin workers.
Expand Down Expand Up @@ -996,12 +1010,13 @@ async fn spawn_opencode_worker_inner(
})
.ok();

let memory_task = sanitize_worker_memory_task(task, &oc_tool_secret_pairs);
state
.deps
.working_memory
.emit(
crate::memory::WorkingMemoryEventType::WorkerSpawned,
format!("Worker spawned (opencode): {task}"),
format!("Worker spawned (opencode): {memory_task}"),
)
.channel(state.channel_id.to_string())
.importance(0.6)
Expand Down Expand Up @@ -1396,7 +1411,10 @@ fn expand_tilde(path: &str) -> std::path::PathBuf {

#[cfg(test)]
mod tests {
use super::{WorkerCompletionError, map_worker_completion_result, spawn_worker_task};
use super::{
WORKING_MEMORY_TASK_MAX_CHARS, WorkerCompletionError, map_worker_completion_result,
sanitize_worker_memory_task, spawn_worker_task,
};
use crate::{ProcessEvent, WorkerId};
use std::sync::Arc;
use std::time::Duration;
Expand All @@ -1414,6 +1432,39 @@ mod tests {
assert!(!success);
}

#[test]
fn worker_spawned_memory_task_redacts_secrets() {
let tool_secret_pairs = vec![("API_KEY".to_string(), "stored-secret".to_string())];
let task = "use stored-secret and sk-ant-abc123456789012345678";
let result = sanitize_worker_memory_task(task, &tool_secret_pairs);

assert!(
!result.contains("stored-secret"),
"stored secret should be redacted in: {result}"
);
assert!(
!result.contains("sk-ant-"),
"leak pattern should be redacted in: {result}"
);
assert!(
result.contains("[REDACTED:API_KEY]"),
"stored secret marker missing in: {result}"
);
assert!(
result.contains("[LEAKED_SECRET_REDACTED]"),
"leak marker missing in: {result}"
);
}

#[test]
fn worker_spawned_memory_task_is_bounded() {
let task = "a".repeat(WORKING_MEMORY_TASK_MAX_CHARS + 100);
let result = sanitize_worker_memory_task(&task, &[]);

assert_eq!(result.chars().count(), WORKING_MEMORY_TASK_MAX_CHARS);
assert!(result.ends_with(" ... [truncated]"));
}

#[tokio::test]
async fn spawn_worker_task_emits_cancelled_completion_event() {
let (event_tx, mut event_rx) = broadcast::channel(8);
Expand Down
91 changes: 87 additions & 4 deletions src/cron/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub struct CronContext {
}

const MAX_CONSECUTIVE_FAILURES: u32 = 3;
const WORKING_MEMORY_CRON_ERROR_MAX_CHARS: usize = 500;

/// RAII guard that clears an `AtomicBool` on drop, ensuring the flag is
/// released even if the holding task panics.
Expand All @@ -153,7 +154,12 @@ fn emit_cron_error(
failure_class: &'static str,
error: &crate::error::Error,
) {
let message = format!("Cron {failure_class}: {job_id}: {error}");
let secrets_guard = context.deps.runtime_config.secrets.load();
let tool_secret_pairs = match (*secrets_guard).as_ref() {
Some(store) => store.tool_secret_pairs(),
None => Vec::new(),
};
let message = cron_error_memory_message(job_id, failure_class, error, &tool_secret_pairs);

// Emit to working memory for agent context awareness
context
Expand All @@ -167,6 +173,19 @@ fn emit_cron_error(
tracing::error!(cron_id = %job_id, failure_class, %error, "cron job execution failed");
}

fn cron_error_memory_message(
job_id: &str,
failure_class: &'static str,
error: &crate::error::Error,
tool_secret_pairs: &[(String, String)],
) -> String {
crate::secrets::scrub::scrub_working_memory_text(
&format!("Cron {failure_class}: {job_id}: {error}"),
tool_secret_pairs,
WORKING_MEMORY_CRON_ERROR_MAX_CHARS,
)
}

#[derive(Debug)]
enum CronRunError {
Execution(crate::error::Error),
Expand Down Expand Up @@ -1730,9 +1749,11 @@ fn normalize_cron_delivery_response(response: OutboundResponse) -> Option<Outbou
#[cfg(test)]
mod tests {
use super::{
CronConfig, CronJob, CronResponseWaitOutcome, CronRunError, await_cron_delivery_response,
cron_response_summary, hour_in_active_window, normalize_active_hours,
normalize_cron_delivery_response, set_job_enabled_state, sync_job_from_store,
CronConfig, CronJob, CronResponseWaitOutcome, CronRunError,
WORKING_MEMORY_CRON_ERROR_MAX_CHARS, await_cron_delivery_response,
cron_error_memory_message, cron_response_summary, hour_in_active_window,
normalize_active_hours, normalize_cron_delivery_response, set_job_enabled_state,
sync_job_from_store,
};
use crate::cron::store::CronStore;
use crate::messaging::target::parse_delivery_target;
Expand Down Expand Up @@ -1998,6 +2019,68 @@ mod tests {
assert_eq!(execution_error.failure_class(), "execution_error");
}

#[test]
fn cron_error_memory_message_redacts_and_bounds_error_text() {
use base64::Engine as _;

let tool_secret_pairs = vec![("API_KEY".to_string(), "stored-secret".to_string())];
let error = crate::error::Error::Other(anyhow::anyhow!(
"{} {} {}",
"stored-secret",
"sk-ant-abc123456789012345678",
"x".repeat(WORKING_MEMORY_CRON_ERROR_MAX_CHARS)
));

let message = cron_error_memory_message(
"daily-digest",
"execution_error",
&error,
&tool_secret_pairs,
);

assert!(
!message.contains("stored-secret"),
"stored secret should be redacted in: {message}"
);
assert!(
!message.contains("sk-ant-"),
"leak pattern should be redacted in: {message}"
);
assert!(
message.contains("[REDACTED:API_KEY]"),
"stored secret marker missing in: {message}"
);
assert!(
message.contains("[LEAKED_SECRET_REDACTED]"),
"leak marker missing in: {message}"
);
assert_eq!(message.chars().count(), WORKING_MEMORY_CRON_ERROR_MAX_CHARS);
assert!(message.ends_with(" ... [truncated]"));

let encoded_secret =
base64::engine::general_purpose::STANDARD.encode("sk-ant-abc123456789012345678");
let encoded_error = crate::error::Error::Other(anyhow::anyhow!(
"{} {}",
encoded_secret,
"x".repeat(WORKING_MEMORY_CRON_ERROR_MAX_CHARS)
));
let encoded_message = cron_error_memory_message(
"daily-digest",
"execution_error",
&encoded_error,
&tool_secret_pairs,
);

assert!(
encoded_message.contains("[WORKING_MEMORY_REDACTED:encoded-secret]"),
"encoded leak marker missing in: {encoded_message}"
);
assert!(
!encoded_message.contains(&encoded_secret),
"encoded secret should be redacted in: {encoded_message}"
);
}

#[tokio::test]
async fn set_job_enabled_state_updates_in_memory_flag() {
let jobs = Arc::new(RwLock::new(HashMap::from([(
Expand Down
Loading
Loading