Skip to content
Merged
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
5 changes: 3 additions & 2 deletions crates/tracedecay-agent-hosts/src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2350,9 +2350,10 @@ fn doctor_check_hooks(
/// `~/.codex/memories/`, the holographic fact store stays the single source
/// of truth and delivery is rendered prompt context only.
fn doctor_suggest_native_memories_off(dc: &mut DoctorCounters, profile_root: &Path, home: &Path) {
if !crate::hooks::memory_inject::memory_injection_enabled(profile_root) {
// An unreadable profile config is the User config section's issue.
let Ok(true) = crate::hooks::memory_inject::memory_injection_enabled(profile_root) else {
return;
}
};
let config_path = codex_config_path(home);
let Ok(config) = load_toml_file(&config_path) else {
return;
Expand Down
10 changes: 6 additions & 4 deletions crates/tracedecay-agent-hosts/src/hooks/memory_inject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
/// Whether daemon-owned memory guidance is enabled: the environment override
/// wins when set, otherwise the configuration of the profile whose data
/// directory is `profile_root` applies.
pub fn memory_injection_enabled(profile_root: &std::path::Path) -> bool {
injection_enabled_from(
pub fn memory_injection_enabled(
profile_root: &std::path::Path,
) -> Result<bool, tracedecay_session_memory::user_config::ConfigSaveError> {
Ok(injection_enabled_from(
std::env::var("TRACEDECAY_MEMORY_INJECTION").ok().as_deref(),
tracedecay_session_memory::user_config::UserConfig::load(profile_root)
tracedecay_session_memory::user_config::UserConfig::load(profile_root)?
.memory_injection_enabled,
)
))
}

fn injection_enabled_from(env_value: Option<&str>, config_flag: bool) -> bool {
Expand Down
3 changes: 2 additions & 1 deletion crates/tracedecay-application/src/advisory/github_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ pub use anchors::{
};
pub use credential_lifecycle::{
GitHubReadOnlyCredentialLifecycleV1, GitHubReadOnlyCredentialPermissionVerifierV1,
GitHubSecretReadErrorV1, GitHubSecretReadPortV1,
GitHubReviewSourceFindingV1, GitHubSecretReadErrorV1, GitHubSecretReadPortV1,
check_configured_github_review_sources_v1,
};
pub use decoder::{
GitHubCanonicalReviewAnchorAuthorityV1, GitHubCanonicalReviewAnchorsV1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::time::Duration;

use serde::Deserialize;
use tracedecay_domain::UserProfileId;
use tracedecay_session_memory::user_config::{ConfigSaveError, read_profile_config};
use zeroize::Zeroizing;

use super::{
Expand Down Expand Up @@ -328,33 +329,24 @@ impl GitHubReadOnlyCredentialLifecycleV1 {
secrets: Arc<dyn GitHubSecretReadPortV1>,
verifier: Arc<dyn GitHubReadOnlyCredentialPermissionVerifierV1>,
) {
let configured = load_configured_repositories(profile_root);
let mut repositories = BTreeMap::new();
for repository in configured.github_review_sources {
let key = (repository.owner.clone(), repository.repository.clone());
match repositories.entry(key) {
Entry::Vacant(entry) => {
entry.insert(Some(repository));
}
Entry::Occupied(mut entry) => {
entry.insert(None);
}
let sources = match read_profile_config::<ConfiguredGitHubRepositoriesV1>(profile_root) {
Ok(configured) => review_sources(configured).0,
Err(error) => {
tracing::warn!(
event = "github_review_sources_unreadable",
%error,
"no GitHub review source is registered for this profile"
);
return;
}
}
};
let Ok(mut registrations) = self.registrations.lock() else {
return;
};
for repository in repositories.into_values().flatten() {
let key = (
profile_id.clone(),
repository.owner.clone(),
repository.repository.clone(),
);
match repository.access {
ConfiguredGitHubAccessV1::Public
if repository.keyring_service.is_none()
&& repository.keyring_account.is_none() =>
{
for source in sources {
match source {
RegistrableGitHubReviewSourceV1::Public { owner, repository } => {
let key = (profile_id.clone(), owner, repository);
if register_profile_github_public_repository_v1(
key.0.clone(),
key.1.clone(),
Expand All @@ -363,15 +355,13 @@ impl GitHubReadOnlyCredentialLifecycleV1 {
registrations.push(ProfileRepositoryCredentialRegistrationV1::Public(key));
}
}
ConfiguredGitHubAccessV1::OsKeyring => {
let (Some(keyring_service), Some(keyring_account)) =
(repository.keyring_service, repository.keyring_account)
else {
continue;
};
if !valid_locator(&keyring_service) || !valid_locator(&keyring_account) {
continue;
}
RegistrableGitHubReviewSourceV1::OsKeyring {
owner,
repository,
keyring_service,
keyring_account,
} => {
let key = (profile_id.clone(), owner, repository);
let authority: Arc<dyn GitHubReadOnlyCredentialAuthorityV1> =
Arc::new(OsKeyringGitHubReadOnlyCredentialAuthorityV1 {
repository_owner: key.1.clone(),
Expand All @@ -393,7 +383,6 @@ impl GitHubReadOnlyCredentialLifecycleV1 {
});
}
}
ConfiguredGitHubAccessV1::Public => {}
}
}
}
Expand Down Expand Up @@ -437,12 +426,135 @@ impl GitHubReadOnlyCredentialLifecycleV1 {
}
}

fn load_configured_repositories(profile_root: &Path) -> ConfiguredGitHubRepositoriesV1 {
let path = profile_root.join("config.toml");
let Ok(contents) = std::fs::read_to_string(&path) else {
return ConfiguredGitHubRepositoriesV1::default();
};
tracedecay_session_memory::user_config::parse_or_warn_default(&path, &contents)
/// A configured `github_review_sources` entry the daemon does not register.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GitHubReviewSourceFindingV1 {
/// Listed more than once, so none of its entries registers.
Duplicate { owner: String, repository: String },
/// `os_keyring` access without both `keyring_service` and `keyring_account`.
MissingKeyringLocator { owner: String, repository: String },
/// A keyring locator that is empty, padded, over 512 bytes, or carries a
/// control character.
InvalidKeyringLocator { owner: String, repository: String },
/// `public` access that also names a keyring locator.
PublicWithKeyringLocator { owner: String, repository: String },
}

impl std::fmt::Display for GitHubReviewSourceFindingV1 {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Duplicate { owner, repository } => {
write!(formatter, "{owner}/{repository} is listed more than once")
}
Self::MissingKeyringLocator { owner, repository } => write!(
formatter,
"{owner}/{repository} uses os_keyring access without keyring_service and \
keyring_account"
),
Self::InvalidKeyringLocator { owner, repository } => write!(
formatter,
"{owner}/{repository} names a keyring locator that is empty, padded, over 512 \
bytes or carries a control character"
),
Self::PublicWithKeyringLocator { owner, repository } => write!(
formatter,
"{owner}/{repository} uses public access but names a keyring locator"
),
}
}
}

enum RegistrableGitHubReviewSourceV1 {
Public {
owner: String,
repository: String,
},
OsKeyring {
owner: String,
repository: String,
keyring_service: String,
keyring_account: String,
},
}

/// Splits the configured review sources into the ones the daemon registers
/// and a finding for each entry it does not.
fn review_sources(
configured: ConfiguredGitHubRepositoriesV1,
) -> (
Vec<RegistrableGitHubReviewSourceV1>,
Vec<GitHubReviewSourceFindingV1>,
) {
let mut findings = Vec::new();
let mut repositories = BTreeMap::new();
for repository in configured.github_review_sources {
match repositories.entry((repository.owner.clone(), repository.repository.clone())) {
Entry::Vacant(entry) => {
entry.insert(Some(repository));
}
Entry::Occupied(mut entry) => {
if entry.insert(None).is_some() {
findings.push(GitHubReviewSourceFindingV1::Duplicate {
owner: repository.owner,
repository: repository.repository,
});
}
}
}
}
let mut sources = Vec::new();
for repository in repositories.into_values().flatten() {
match registrable_review_source(repository) {
Ok(source) => sources.push(source),
Err(finding) => findings.push(finding),
}
}
(sources, findings)
}

fn registrable_review_source(
configured: ConfiguredGitHubRepositoryV1,
) -> Result<RegistrableGitHubReviewSourceV1, GitHubReviewSourceFindingV1> {
let ConfiguredGitHubRepositoryV1 {
owner,
repository,
access,
keyring_service,
keyring_account,
} = configured;
match (access, keyring_service, keyring_account) {
(ConfiguredGitHubAccessV1::Public, None, None) => {
Ok(RegistrableGitHubReviewSourceV1::Public { owner, repository })
}
(ConfiguredGitHubAccessV1::Public, _, _) => {
Err(GitHubReviewSourceFindingV1::PublicWithKeyringLocator { owner, repository })
}
(ConfiguredGitHubAccessV1::OsKeyring, Some(keyring_service), Some(keyring_account))
if valid_locator(&keyring_service) && valid_locator(&keyring_account) =>
{
Ok(RegistrableGitHubReviewSourceV1::OsKeyring {
owner,
repository,
keyring_service,
keyring_account,
})
}
(ConfiguredGitHubAccessV1::OsKeyring, Some(_), Some(_)) => {
Err(GitHubReviewSourceFindingV1::InvalidKeyringLocator { owner, repository })
}
(ConfiguredGitHubAccessV1::OsKeyring, _, _) => {
Err(GitHubReviewSourceFindingV1::MissingKeyringLocator { owner, repository })
}
}
}

/// The configured review sources the daemon does not register, read the way
/// it registers them. An error means it registered none.
pub fn check_configured_github_review_sources_v1(
profile_root: &Path,
) -> Result<Vec<GitHubReviewSourceFindingV1>, ConfigSaveError> {
read_profile_config::<ConfiguredGitHubRepositoriesV1>(profile_root)
.map(|configured| review_sources(configured).1)
}

fn valid_locator(value: &str) -> bool {
Expand All @@ -464,7 +576,8 @@ mod tests {

use super::{
GitHubProviderPermissionVerifierV1, GitHubReadOnlyCredentialLifecycleV1,
GitHubSecretReadErrorV1, GitHubSecretReadPortV1,
GitHubReviewSourceFindingV1, GitHubSecretReadErrorV1, GitHubSecretReadPortV1,
check_configured_github_review_sources_v1,
};
use crate::advisory::github_runtime::{
GitHubReadPermissionV1, ProfileGitHubReadOnlyCredentialMountOutcomeV1,
Expand Down Expand Up @@ -547,6 +660,24 @@ repository = "duplicate"
access = "os_keyring"
keyring_service = "tracedecay.github"
keyring_account = "read"

[[github_review_sources]]
owner = "ScriptedAlchemy"
repository = "public-with-locator"
access = "public"
keyring_service = "tracedecay.github"

[[github_review_sources]]
owner = "ScriptedAlchemy"
repository = "keyring-unnamed"
access = "os_keyring"

[[github_review_sources]]
owner = "ScriptedAlchemy"
repository = "keyring-padded"
access = "os_keyring"
keyring_service = " tracedecay.github"
keyring_account = "read"
"#,
)
.expect("profile configuration");
Expand Down Expand Up @@ -672,6 +803,40 @@ keyring_account = "read"
),
ProfileGitHubReadOnlyCredentialMountOutcomeV1::NotConfigured
);
for repository in ["public-with-locator", "keyring-unnamed", "keyring-padded"] {
assert_eq!(
mount_profile_github_read_only_credential_authority_v1(
&profile_id,
"ScriptedAlchemy",
repository,
),
ProfileGitHubReadOnlyCredentialMountOutcomeV1::NotConfigured,
"{repository}"
);
}
let owner = || "ScriptedAlchemy".to_owned();
assert_eq!(
check_configured_github_review_sources_v1(&profile_root)
.expect("the profile configuration parses"),
[
GitHubReviewSourceFindingV1::Duplicate {
owner: owner(),
repository: "duplicate".to_owned(),
},
GitHubReviewSourceFindingV1::InvalidKeyringLocator {
owner: owner(),
repository: "keyring-padded".to_owned(),
},
GitHubReviewSourceFindingV1::MissingKeyringLocator {
owner: owner(),
repository: "keyring-unnamed".to_owned(),
},
GitHubReviewSourceFindingV1::PublicWithKeyringLocator {
owner: owner(),
repository: "public-with-locator".to_owned(),
},
]
);
server.join().expect("permission verifier server");

lifecycle.shutdown();
Expand Down
4 changes: 2 additions & 2 deletions crates/tracedecay-cli/src/agent_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,10 +1039,10 @@ fn apply_canonical_component_set(
Ok(HostLifecycleResult::Applied)
}

fn load_host_lifecycle_user_config(
pub(crate) fn load_host_lifecycle_user_config(
profile: &ProfileRoot,
) -> tracedecay_domain::errors::Result<UserConfig> {
UserConfig::load_strict(profile.data_dir()).map_err(|error| {
UserConfig::load(profile.data_dir()).map_err(|error| {
tracedecay_domain::errors::TraceDecayError::Config {
message: format!("failed to load host lifecycle policy: {error}"),
}
Expand Down
Loading
Loading