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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,10 @@ impl LocalKernel {
}

fn main() {
env_logger::init();
// ureq debug traces include full URLs; GitHub logs only sanitized paths.
env_logger::Builder::from_default_env()
.filter_module("ureq", log::LevelFilter::Off)
.init();

if std::env::args().any(|arg| arg == "--cu-smoke") {
use std::io::Write as _;
Expand Down
109 changes: 108 additions & 1 deletion crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,12 @@ impl HostLink {

/// Restore before starting the pump or admitting new work.
pub fn restore_outbox(&self, storage: Arc<dyn outbox::Storage>) -> Result<(), ProtocolError> {
let entries = storage.load()?;
let mut entries = storage.load()?;
let original_count = entries.len();
entries.retain(|entry| !entry.command.contains_secret());
if entries.len() != original_count {
storage.save(&entries)?;
}
if entries.len() > outbox::MAX_ITEMS
|| serde_json::to_vec(&entries)
.map_err(outbox::storage_error)?
Expand Down Expand Up @@ -357,6 +362,20 @@ impl HostLink {
}

fn enqueue(&self, id: u64, command: Command) -> Result<(), ProtocolError> {
let state = command
.contains_secret()
.then(|| self.inner.connection_state.lock().unwrap());
if state.as_ref().is_some_and(|state| {
!matches!(
**state,
ConnectionState::Connected { .. } | ConnectionState::Syncing { .. }
)
}) {
return Err(error(
"disconnected",
"Saving a secret requires a connection",
));
}
let entry = outbox::Entry {
key: uuid::Uuid::new_v4().to_string(),
command,
Expand Down Expand Up @@ -411,6 +430,7 @@ impl HostLink {
delivery.failed.pop_front();
}
drop(delivery);
drop(state);
let _ = self.inner.delivery_changes.0.try_send(());
let mut rejected = false;
for write in evicted {
Expand Down Expand Up @@ -906,6 +926,7 @@ fn snapshot(delivery: &Delivery) -> Vec<outbox::Entry> {
delivery
.writes
.iter()
.filter(|write| !write.entry.command.contains_secret())
.map(|write| write.entry.clone())
.collect()
}
Expand Down Expand Up @@ -1023,6 +1044,92 @@ mod tests {
}
}

#[test]
fn secrets_keep_reconnect_delivery_keys_without_entering_the_persisted_outbox() {
let storage = Arc::new(MemoryStorage::default());
let (to_host, outgoing) = async_channel::unbounded();
let (incoming, from_host) = async_channel::unbounded();
let link = HostLink::new(to_host, from_host);
link.restore_outbox(storage.clone()).unwrap();
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let mut pump = std::pin::pin!(link.pump_with_timer(std::future::pending::<()>));
for command in [
Command::SetGitHubToken {
host: "github.com".into(),
token: Some("fixture-private-token".into()),
},
Command::SetProfileSecret {
profile_id: "claude".into(),
name: "KEY".into(),
value: Some("fixture-private-token".into()),
},
Command::CreateThirdPartyProfile {
name: "sample".into(),
base_url: "https://example.com".into(),
model: None,
api_key: "fixture-private-token".into(),
},
] {
link.dispatch(command.clone()).unwrap();
let request =
tcode_protocol::decode_client_line(&outgoing.try_recv().unwrap()).unwrap();
assert!(request.key.is_some());
assert_eq!(request.payload, ClientPayload::Command(command));
assert!(storage.0.lock().unwrap().is_empty());
link.set_connection_state(ConnectionState::Reconnecting {
attempt: 1,
reason: None,
});
link.set_connection_state(ConnectionState::Syncing { path: None });
let replay = tcode_protocol::decode_client_line(&outgoing.try_recv().unwrap()).unwrap();
assert_eq!(replay.key, request.key);
assert_eq!(replay.payload, request.payload);
incoming
.try_send(
encode_line(&HostMessage::Ack {
id: replay.id,
result: Ok(CommandResponse::Unit),
})
.unwrap(),
)
.unwrap();
assert!(pump.as_mut().poll(&mut cx).is_pending());
assert!(link.pending_commands().is_empty());
assert!(storage.0.lock().unwrap().is_empty());
}
link.set_connection_state(ConnectionState::Reconnecting {
attempt: 1,
reason: None,
});
assert!(
link.dispatch(Command::SetGitHubToken {
host: "github.com".into(),
token: Some("fixture-private-token".into())
})
.is_err()
);
assert!(storage.0.lock().unwrap().is_empty());
// Older clients persisted provider secrets. Restore removes those copies
// rather than replaying them after a process restart.
storage.0.lock().unwrap().push(outbox::Entry {
key: "old-secret".into(),
command: Command::SetProfileSecret {
profile_id: "claude".into(),
name: "KEY".into(),
value: Some("fixture-private-token".into()),
},
});
let (send, receive) = async_channel::unbounded();
let (_send, receive_host) = async_channel::unbounded();
let restored = HostLink::new(send, receive_host);
restored.restore_outbox(storage.clone()).unwrap();
restored.set_connection_state(ConnectionState::Syncing { path: None });
assert!(receive.try_recv().is_err());
assert!(storage.0.lock().unwrap().is_empty());
link.close();
assert!(pump.as_mut().poll(&mut cx).is_ready());
}

#[test]
fn durable_writes_recreate_in_order_and_oldest_surplus_fails() {
let storage = Arc::new(MemoryStorage::default());
Expand Down
67 changes: 67 additions & 0 deletions crates/core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,48 @@ use orchestrate_legacy::LegacyOrchestrateModel;
mod orchestrate_fleet;
mod orchestrate_legacy;

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubSettings {
#[serde(default)]
pub hosts: BTreeMap<String, GitHubHostSettings>,
/// Host-authored discovery; never persisted in settings.json.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub status: BTreeMap<String, GitHubCredentialStatus>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubHostSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub account: Option<String>,
}

impl Default for GitHubHostSettings {
fn default() -> Self {
Self {
enabled: true,
account: None,
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GitHubCredentialSource {
Saved,
Env,
Gh,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubCredentialStatus {
pub token_set: bool,
pub source: Option<GitHubCredentialSource>,
pub accounts: Vec<String>,
pub env_overrides_account: bool,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
Expand Down Expand Up @@ -732,6 +774,11 @@ impl PluginManagementSettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "content", rename_all = "snake_case")]
pub enum SettingsPatch {
GitHubHost {
host: String,
enabled: Option<bool>,
account: Option<Option<String>>,
},
Language(Option<String>),
ThemeMode(ThemeMode),
WordWrapDiffs(bool),
Expand Down Expand Up @@ -820,6 +867,8 @@ impl BrowserSettings {
// agent crate derives only `PartialEq` for.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Settings {
#[serde(default)]
pub github: GitHubSettings,
/// None follows the operating-system language.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
Expand Down Expand Up @@ -985,6 +1034,7 @@ const fn default_auto_archive_keep_count() -> usize {
impl Default for Settings {
fn default() -> Self {
Self {
github: GitHubSettings::default(),
language: None,
providers: BTreeMap::new(),
profiles: BTreeMap::new(),
Expand Down Expand Up @@ -1032,6 +1082,23 @@ impl Settings {
/// Apply one field-scoped mutation without replacing sibling fields.
pub fn apply(&mut self, patch: SettingsPatch) {
match patch {
SettingsPatch::GitHubHost {
host,
enabled,
account,
} => {
let entry = self
.github
.hosts
.entry(host.trim().to_ascii_lowercase())
.or_default();
if let Some(enabled) = enabled {
entry.enabled = enabled;
}
if let Some(account) = account {
entry.account = account.filter(|value| !value.trim().is_empty());
}
}
SettingsPatch::Language(value) => self.language = value,
SettingsPatch::ThemeMode(value) => self.theme_mode = value,
SettingsPatch::WordWrapDiffs(value) => self.word_wrap_diffs = value,
Expand Down
5 changes: 4 additions & 1 deletion crates/headless/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ const MIGRATION_REPORT_INTERVAL: Duration = Duration::from_secs(1);
static INTERRUPTED: AtomicBool = AtomicBool::new(false);

fn main() {
env_logger::init();
// ureq debug traces include full URLs; GitHub logs only sanitized paths.
env_logger::Builder::from_default_env()
.parse_filters("ureq=off")
.init();
if let Err(error) = run(std::env::args().skip(1).collect()) {
eprintln!("tcode-headless: {error}");
std::process::exit(1);
Expand Down
16 changes: 16 additions & 0 deletions crates/protocol/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ pub enum Command {
attachment_paths: Vec<PathBuf>,
},
ReloadProvider,
SetGitHubToken {
host: String,
token: Option<String>,
},
RefreshGitHubCredentials,
SetProfileSecret {
profile_id: String,
name: String,
Expand Down Expand Up @@ -482,6 +487,16 @@ impl Command {
}
}

/// Secret-bearing payloads may be retained in memory, never on disk.
pub fn contains_secret(&self) -> bool {
matches!(
self,
Self::SetGitHubToken { .. }
| Self::SetProfileSecret { .. }
| Self::CreateThirdPartyProfile { .. }
)
}

/// Idempotent controls and reads do not need retained delivery.
/// All other variants are retained writes, including settings assignments:
/// repeating an old assignment after a newer one would undo user intent.
Expand All @@ -492,6 +507,7 @@ impl Command {
| Self::PreviewReply { .. }
| Self::ShutdownAllAndFlush
| Self::OpenLatestSession
| Self::RefreshGitHubCredentials
| Self::RefreshProviderStatus
| Self::RefreshProviderUsage
| Self::CheckProviderVersions
Expand Down
3 changes: 2 additions & 1 deletion crates/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ pub use wire::{
//
// Unreleased: replaces SessionActivity.background_only with waiting, which
// also covers unfinished child threads; adds the CreateNewProject and
// StartScratchDraft commands.
// StartScratchDraft commands; adds per-host GitHub settings, credential source
// discovery, SetGitHubToken and RefreshGitHubCredentials.
pub const PROTOCOL_VERSION: u32 = 10;

#[cfg(test)]
Expand Down
2 changes: 2 additions & 0 deletions crates/runtime/src/app/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ impl AppState {
| Command::AutoArchiveSweep { .. }
| Command::PreviewReply { .. }
| Command::ReloadProvider
| Command::SetGitHubToken { .. }
| Command::RefreshGitHubCredentials
| Command::SetProfileSecret { .. }
| Command::UpdateProfileSettings { .. }
| Command::CreateThirdPartyProfile { .. }
Expand Down
10 changes: 10 additions & 0 deletions crates/runtime/src/app/command_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ impl AppState {
};
self.validate_plugin_command(command)?;
match command {
Command::SetGitHubToken { host, .. }
| Command::PatchSettings {
patch: tcode_core::settings::SettingsPatch::GitHubHost { host, .. },
} if tcode_services::github::normalize_host(host).is_err() => {
return Err(error(
"invalid_github_host",
"Use a GitHub hostname without a URL or path.",
));
}

Command::DeleteProfile { profile_id }
if Settings::is_builtin_profile_id(profile_id) =>
{
Expand Down
Loading
Loading