feat(backend): support des providers OpenCode cloud (#92)
Ajoute le catalogue statique de providers OpenCode (lot B3), le stockage sécurisé des secrets (SecretStore + adapter infrastructure), et les use cases SaveOpenCodeProviderProfile/DeleteProfile câblés en composition root. Couvre le fix B1 et les tests de régression demandés par QA. cargo build --workspace propre, cargo test --workspace -- --test-threads=1 intégralement vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -23,6 +23,10 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
# AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2).
|
||||
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
|
||||
# dependency here rather than adding a new crate to the tree.
|
||||
ring = "0.17"
|
||||
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
|
||||
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
|
||||
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||
|
||||
@ -4,8 +4,8 @@ use std::sync::Arc;
|
||||
|
||||
use application::McpRuntime;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::SessionPlan;
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::ports::{SecretStore, SessionPlan};
|
||||
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use domain::{
|
||||
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
||||
ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
|
||||
@ -92,25 +92,57 @@ pub struct TicketAssistantEnvironmentPreparer {
|
||||
app_data_dir: String,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
||||
/// Resolves the literal API key of an [`OpenCodeProviderConfig`] (ticket #92,
|
||||
/// lot B3) just before writing `opencode.json`. A missing/undecryptable
|
||||
/// secret fails the ticket-assistant launch — see
|
||||
/// [`Self::resolve_opencode_provider_api_key`].
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl TicketAssistantEnvironmentPreparer {
|
||||
/// Builds the preparer from filesystem, app-data dir, runtime and MCP resolver.
|
||||
/// Builds the preparer from filesystem, app-data dir, runtime, MCP resolver
|
||||
/// and secret store.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: impl Into<String>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
runtime,
|
||||
mcp_runtime,
|
||||
secret_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the literal API key of an [`OpenCodeProviderConfig`] through the
|
||||
/// injected [`SecretStore`] (ticket #92, lot B3). Fails hard — never spawns
|
||||
/// the ticket assistant with a dead/absent key.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`RuntimeError::Invocation`] if the secret is absent (corrupted/deleted out
|
||||
/// from under the profile) or the store fails.
|
||||
async fn resolve_opencode_provider_api_key(
|
||||
&self,
|
||||
provider: &OpenCodeProviderConfig,
|
||||
) -> Result<String, RuntimeError> {
|
||||
self.secret_store
|
||||
.get(&provider.api_key_ref)
|
||||
.await
|
||||
.map_err(|e| RuntimeError::Invocation(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
RuntimeError::Invocation(format!(
|
||||
"no secret found for OpenCode provider `{}` (secret ref `{}`)",
|
||||
provider.provider_id,
|
||||
provider.api_key_ref.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result<ProjectPath, RuntimeError> {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
let project_id = project.id.as_uuid().simple();
|
||||
@ -211,7 +243,19 @@ impl TicketAssistantEnvironmentPreparer {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(opencode) = profile.opencode.as_ref() else {
|
||||
let body = if let Some(opencode) = profile.opencode.as_ref() {
|
||||
opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
|
||||
.to_string()
|
||||
} else if let Some(provider) = profile.opencode_provider.as_ref() {
|
||||
let api_key = self.resolve_opencode_provider_api_key(provider).await?;
|
||||
opencode_provider_config_json(
|
||||
provider,
|
||||
&api_key,
|
||||
project.root.as_str(),
|
||||
runtime.as_ref(),
|
||||
)
|
||||
.to_string()
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
let config_path = join(cwd, target);
|
||||
@ -222,8 +266,6 @@ impl TicketAssistantEnvironmentPreparer {
|
||||
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
|
||||
self.create_dir(dir).await?;
|
||||
}
|
||||
let body = opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
|
||||
.to_string();
|
||||
self.write_file(&config_path, body.as_bytes()).await?;
|
||||
env.extend([
|
||||
("OPENCODE_CONFIG".to_owned(), config_path),
|
||||
@ -375,6 +417,77 @@ fn opencode_config_json(
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
/// Renders the OpenCode config for a **cloud** provider profile (ticket #92, lot
|
||||
/// B3), mirroring [`opencode_config_json`]. See the sibling implementation in
|
||||
/// `application::agent::lifecycle` for the rationale (duplication flagged as
|
||||
/// separate debt, not tripled here).
|
||||
fn opencode_provider_config_json(
|
||||
config: &OpenCodeProviderConfig,
|
||||
api_key: &str,
|
||||
project_root: &str,
|
||||
runtime: Option<&McpRuntime>,
|
||||
) -> Value {
|
||||
let mut root = Map::new();
|
||||
root.insert(
|
||||
"$schema".to_owned(),
|
||||
Value::String("https://opencode.ai/config.json".to_owned()),
|
||||
);
|
||||
root.insert(
|
||||
"model".to_owned(),
|
||||
Value::String(format!("{}/{}", config.provider_id, config.model)),
|
||||
);
|
||||
root.insert(
|
||||
"provider".to_owned(),
|
||||
json!({
|
||||
config.provider_id.as_str(): {
|
||||
"options": {
|
||||
"apiKey": api_key
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (command, args) = match runtime {
|
||||
Some(rt) => (
|
||||
rt.exe.clone(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
rt.endpoint.clone(),
|
||||
"--project".to_owned(),
|
||||
rt.project_id.clone(),
|
||||
"--requester".to_owned(),
|
||||
rt.requester.clone(),
|
||||
],
|
||||
),
|
||||
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
|
||||
};
|
||||
let command_array = std::iter::once(command)
|
||||
.chain(args)
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>();
|
||||
root.insert(
|
||||
"mcp".to_owned(),
|
||||
json!({
|
||||
"idea": {
|
||||
"type": "local",
|
||||
"command": command_array,
|
||||
"cwd": project_root,
|
||||
"enabled": true,
|
||||
"timeout": 15000
|
||||
}
|
||||
}),
|
||||
);
|
||||
root.insert(
|
||||
"permission".to_owned(),
|
||||
json!({
|
||||
"bash": "ask",
|
||||
"edit": "ask"
|
||||
}),
|
||||
);
|
||||
Value::Object(root)
|
||||
}
|
||||
|
||||
fn codex_config_toml(
|
||||
existing: Option<&str>,
|
||||
mcp_declaration: &str,
|
||||
|
||||
@ -102,7 +102,8 @@ pub use store::{
|
||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore,
|
||||
NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
FsSecretStore, FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder,
|
||||
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@ mod memory;
|
||||
mod permission;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod secrets;
|
||||
mod skill;
|
||||
mod template;
|
||||
mod vector;
|
||||
@ -37,6 +38,7 @@ pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use secrets::FsSecretStore;
|
||||
pub use skill::FsSkillStore;
|
||||
pub use template::FsTemplateStore;
|
||||
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
|
||||
|
||||
320
crates/infrastructure/src/store/secrets.rs
Normal file
320
crates/infrastructure/src/store/secrets.rs
Normal file
@ -0,0 +1,320 @@
|
||||
//! [`FsSecretStore`] — encrypted-at-rest [`SecretStore`] adapter (ticket #92, lot B2).
|
||||
//!
|
||||
//! ```text
|
||||
//! <app_data_dir>/
|
||||
//! ├── secret.key # 32 raw bytes, AES-256-GCM key material, chmod 0600 (Unix)
|
||||
//! └── secrets.json # { version, entries: { <secretRefId>: "<hex nonce+ciphertext>" } }
|
||||
//! ```
|
||||
//!
|
||||
//! Unlike `profiles.json` (plain JSON), `secrets.json` never carries a plaintext
|
||||
//! value: each entry is `nonce (12B) || AES-256-GCM(value)`, hex-encoded. The key
|
||||
//! is generated once (random, `ring::rand::SystemRandom`) and persisted next to
|
||||
//! it. No OS keyring integration in v1 (assumed debt, documented by Architect) —
|
||||
//! a local attacker with read access to `secret.key` recovers every secret; the
|
||||
//! win over `profiles.json` is that `secrets.json` alone (e.g. leaked in a backup
|
||||
//! that excludes `secret.key`, or read by a process without access to the key
|
||||
//! file's restrictive permissions) is inert ciphertext.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use ring::aead::{self, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey};
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ports::{FileSystem, FsError, RemotePath, SecretRef, SecretStore, SecretStoreError};
|
||||
|
||||
const SECRETS_FILE: &str = "secrets.json";
|
||||
const KEY_FILE: &str = "secret.key";
|
||||
const SECRETS_VERSION: u32 = 1;
|
||||
const KEY_LEN: usize = 32; // AES-256
|
||||
const NONCE_LEN: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SecretsDoc {
|
||||
version: u32,
|
||||
/// `SecretRef` id -> hex(nonce || ciphertext || tag).
|
||||
entries: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for SecretsDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SECRETS_VERSION,
|
||||
entries: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-use nonce sequence wrapping one 12-byte value (ring's streaming AEAD
|
||||
/// API insists on a `NonceSequence`, but every seal/open here uses exactly one
|
||||
/// fresh/parsed nonce, never a stream).
|
||||
struct OnceNonce(Option<[u8; NONCE_LEN]>);
|
||||
|
||||
impl NonceSequence for OnceNonce {
|
||||
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
|
||||
let bytes = self.0.take().ok_or(ring::error::Unspecified)?;
|
||||
Ok(Nonce::assume_unique_for_key(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypted-at-rest [`SecretStore`] port implementation.
|
||||
///
|
||||
/// Cheap to clone (everything behind `Arc`); built once at the composition root.
|
||||
#[derive(Clone)]
|
||||
pub struct FsSecretStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsSecretStore {
|
||||
/// Builds the store from an injected [`FileSystem`] and the app-data dir
|
||||
/// (same machine-local directory as `profiles.json`).
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self, file: &str) -> RemotePath {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{file}"))
|
||||
}
|
||||
|
||||
/// Loads the key material, generating and persisting a fresh random key on
|
||||
/// first use. The key file is written with `0600` permissions on Unix
|
||||
/// (best-effort no-op elsewhere).
|
||||
async fn load_or_init_key(&self) -> Result<[u8; KEY_LEN], SecretStoreError> {
|
||||
let key_path = self.path(KEY_FILE);
|
||||
match self.fs.read(&key_path).await {
|
||||
Ok(bytes) => {
|
||||
let key: [u8; KEY_LEN] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| SecretStoreError::Crypto("secret.key has wrong length".into()))?;
|
||||
Ok(key)
|
||||
}
|
||||
Err(FsError::NotFound(_)) => {
|
||||
let mut key = [0_u8; KEY_LEN];
|
||||
SystemRandom::new()
|
||||
.fill(&mut key)
|
||||
.map_err(|_| SecretStoreError::Crypto("failed to generate secret key".into()))?;
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&key_path, &key)
|
||||
.await
|
||||
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
|
||||
restrict_permissions(key_path.as_str());
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => Err(SecretStoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_doc(&self) -> Result<SecretsDoc, SecretStoreError> {
|
||||
match self.fs.read(&self.path(SECRETS_FILE)).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||
.map_err(|e| SecretStoreError::Io(format!("secrets.json parse error: {e}"))),
|
||||
Err(FsError::NotFound(_)) => Ok(SecretsDoc::default()),
|
||||
Err(e) => Err(SecretStoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_doc(&self, doc: &SecretsDoc) -> Result<(), SecretStoreError> {
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
|
||||
let bytes = serde_json::to_vec_pretty(doc)
|
||||
.map_err(|e| SecretStoreError::Io(format!("secrets.json serialise error: {e}")))?;
|
||||
self.fs
|
||||
.write(&self.path(SECRETS_FILE), &bytes)
|
||||
.await
|
||||
.map_err(|e| SecretStoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn seal(key: &[u8; KEY_LEN], plaintext: &str) -> Result<String, SecretStoreError> {
|
||||
let mut nonce_bytes = [0_u8; NONCE_LEN];
|
||||
SystemRandom::new()
|
||||
.fill(&mut nonce_bytes)
|
||||
.map_err(|_| SecretStoreError::Crypto("failed to generate nonce".into()))?;
|
||||
let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
|
||||
.map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
|
||||
let mut sealing = SealingKey::new(unbound, OnceNonce(Some(nonce_bytes)));
|
||||
let mut in_out = plaintext.as_bytes().to_vec();
|
||||
sealing
|
||||
.seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)
|
||||
.map_err(|_| SecretStoreError::Crypto("seal failed".into()))?;
|
||||
let mut out = Vec::with_capacity(NONCE_LEN + in_out.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&in_out);
|
||||
Ok(hex::encode(out))
|
||||
}
|
||||
|
||||
fn open(key: &[u8; KEY_LEN], encoded: &str) -> Result<String, SecretStoreError> {
|
||||
let bytes =
|
||||
hex::decode(encoded).map_err(|e| SecretStoreError::Crypto(format!("bad hex: {e}")))?;
|
||||
if bytes.len() < NONCE_LEN {
|
||||
return Err(SecretStoreError::Crypto("ciphertext too short".into()));
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
|
||||
let mut nonce_arr = [0_u8; NONCE_LEN];
|
||||
nonce_arr.copy_from_slice(nonce_bytes);
|
||||
let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
|
||||
.map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
|
||||
let mut opening = OpeningKey::new(unbound, OnceNonce(Some(nonce_arr)));
|
||||
let mut in_out = ciphertext.to_vec();
|
||||
let plaintext = opening
|
||||
.open_in_place(aead::Aad::empty(), &mut in_out)
|
||||
.map_err(|_| SecretStoreError::Crypto("decryption failed".into()))?;
|
||||
String::from_utf8(plaintext.to_vec())
|
||||
.map_err(|e| SecretStoreError::Crypto(format!("decrypted value is not utf8: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretStore for FsSecretStore {
|
||||
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> {
|
||||
let enc_key = self.load_or_init_key().await?;
|
||||
let mut doc = self.read_doc().await?;
|
||||
let sealed = Self::seal(&enc_key, value)?;
|
||||
doc.entries.insert(key.as_str().to_owned(), sealed);
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
|
||||
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError> {
|
||||
let doc = self.read_doc().await?;
|
||||
let Some(sealed) = doc.entries.get(key.as_str()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let enc_key = self.load_or_init_key().await?;
|
||||
Self::open(&enc_key, sealed).map(Some)
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
doc.entries.remove(key.as_str());
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_permissions(path: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
let mut perms = metadata.permissions();
|
||||
perms.set_mode(0o600);
|
||||
let _ = std::fs::set_permissions(path, perms);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_permissions(_path: &str) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(label: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!("idea-secrets-store-{label}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(dir: &TempDir) -> FsSecretStore {
|
||||
FsSecretStore::new(
|
||||
Arc::new(crate::fs::LocalFileSystem::new()),
|
||||
dir.0.to_string_lossy().into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_then_get_round_trips_plaintext() {
|
||||
let dir = TempDir::new("roundtrip");
|
||||
let store = store(&dir);
|
||||
let key = SecretRef::new("secret-a");
|
||||
store.put(&key, "sk-live-abc123").await.unwrap();
|
||||
let got = store.get(&key).await.unwrap();
|
||||
assert_eq!(got, Some("sk-live-abc123".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_key_returns_none() {
|
||||
let dir = TempDir::new("missing");
|
||||
let store = store(&dir);
|
||||
let got = store.get(&SecretRef::new("nope")).await.unwrap();
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_the_entry() {
|
||||
let dir = TempDir::new("delete");
|
||||
let store = store(&dir);
|
||||
let key = SecretRef::new("secret-b");
|
||||
store.put(&key, "value").await.unwrap();
|
||||
store.delete(&key).await.unwrap();
|
||||
assert_eq!(store.get(&key).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_file_never_contains_the_plaintext_value() {
|
||||
let dir = TempDir::new("plaintext-leak");
|
||||
let store = store(&dir);
|
||||
store
|
||||
.put(&SecretRef::new("secret-c"), "super-secret-literal")
|
||||
.await
|
||||
.unwrap();
|
||||
let raw = tokio::fs::read_to_string(dir.0.join(SECRETS_FILE))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!raw.contains("super-secret-literal"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_file_has_owner_only_permissions_on_unix() {
|
||||
let dir = TempDir::new("perms");
|
||||
let store = store(&dir);
|
||||
store.put(&SecretRef::new("secret-d"), "value").await.unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let metadata = std::fs::metadata(dir.0.join(KEY_FILE)).unwrap();
|
||||
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_reuses_the_same_key_across_calls() {
|
||||
let dir = TempDir::new("reuse-key");
|
||||
let store = store(&dir);
|
||||
store.put(&SecretRef::new("a"), "value-a").await.unwrap();
|
||||
store.put(&SecretRef::new("b"), "value-b").await.unwrap();
|
||||
assert_eq!(
|
||||
store.get(&SecretRef::new("a")).await.unwrap(),
|
||||
Some("value-a".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
store.get(&SecretRef::new("b")).await.unwrap(),
|
||||
Some("value-b".to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ use domain::{
|
||||
SpawnSpec,
|
||||
};
|
||||
use infrastructure::{
|
||||
FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
|
||||
FsAssistantContextStore, FsSecretStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -195,6 +195,7 @@ async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_da
|
||||
requester: requester.clone(),
|
||||
})
|
||||
}),
|
||||
Arc::new(FsSecretStore::new(fs.clone(), app_data_dir.clone())),
|
||||
);
|
||||
|
||||
let env = preparer
|
||||
|
||||
Reference in New Issue
Block a user