Skip to content

Commit b0a7b5b

Browse files
committed
feat(agents): config repos, global secrets, sandbox reliability
- Add per-agent config repo (URL + branch) for .claude/ overlay - Add global secrets (agent_secrets table) with AES-256-GCM encryption - Secrets defined at settings level, not per-project - Routes at /settings/secrets with SettingsRead/Write permissions - Migration renames project_secrets → agent_secrets, drops project_id - Add secrets management UI in Agent Sandbox Settings page - Sandbox image rebuild now always builds locally (skip Docker Hub pull) - Add /opt/claude-backup in Dockerfile for named-volume resilience - Post-start exec restores Claude CLI if masked by stale named volume - Use full path /home/temps/.local/bin/claude in exec commands - Stream rebuild progress via SSE to frontend - Log container ID and image name in agent run logs - Add global config repo setting in platform settings
1 parent 5221cc2 commit b0a7b5b

34 files changed

+2044
-53
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ regex = "1.11"
244244
# ============================================================================
245245
flate2 = "1.0"
246246
tar = "0.4.45"
247+
walkdir = "2"
247248
zip = "2.2"
248249
zstd = "0.13"
249250

crates/temps-agents/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ bollard = { workspace = true }
3737
bytes = { workspace = true }
3838
http-body-util = { workspace = true }
3939
tar = "0.4"
40+
walkdir = { workspace = true }
4041
pulldown-cmark = "0.12"
4142
libc = { workspace = true }
4243
rand = { workspace = true }

crates/temps-agents/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ pub enum AgentError {
7474

7575
#[error("Sandbox provider '{provider}' unavailable: {reason}")]
7676
SandboxProviderUnavailable { provider: String, reason: String },
77+
78+
#[error("Secret '{name}' not found")]
79+
SecretNotFound { name: String },
7780
}
7881

7982
impl From<sea_orm::DbErr> for AgentError {

crates/temps-agents/src/handlers/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ impl From<AgentError> for Problem {
9090
.with_title("Sandbox Provider Unavailable")
9191
.with_detail(error.to_string())
9292
}
93+
AgentError::SecretNotFound { .. } => problemdetails::new(StatusCode::NOT_FOUND)
94+
.with_title("Secret Not Found")
95+
.with_detail(error.to_string()),
9396
}
9497
}
9598
}
@@ -202,6 +205,10 @@ pub struct AgentConfigResponse {
202205
pub deliverable: String,
203206
/// None = use global sandbox setting, true = force on, false = force off
204207
pub sandbox_enabled: Option<bool>,
208+
/// Private config repo containing .claude/ directory (skills, MCP, plugins).
209+
pub config_repo_url: Option<String>,
210+
/// Branch of the config repo to use.
211+
pub config_repo_branch: Option<String>,
205212
pub created_at: String,
206213
pub updated_at: String,
207214
}
@@ -228,6 +235,8 @@ impl From<project_agents::Model> for AgentConfigResponse {
228235
branch_prefix: model.branch_prefix,
229236
deliverable: model.deliverable,
230237
sandbox_enabled: model.sandbox_enabled,
238+
config_repo_url: model.config_repo_url,
239+
config_repo_branch: model.config_repo_branch,
231240
created_at: model.created_at.to_rfc3339(),
232241
updated_at: model.updated_at.to_rfc3339(),
233242
}

crates/temps-agents/src/handlers/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod autofixer;
22
pub mod config;
33
pub mod preview_gateway;
44
pub mod runs;
5+
pub mod secrets;
56
pub mod trigger;
67

78
use axum::Router;
@@ -11,6 +12,7 @@ use crate::services::autofixer::AutofixerService;
1112
use crate::services::config_service::AgentConfigService;
1213
use crate::services::executor::AgentExecutor;
1314
use crate::services::run_service::AgentRunService;
15+
use crate::services::secret_service::SecretService;
1416

1517
pub struct AppState {
1618
pub db: Arc<sea_orm::DatabaseConnection>,
@@ -20,6 +22,7 @@ pub struct AppState {
2022
pub executor: Arc<AgentExecutor>,
2123
pub audit_service: Arc<dyn temps_core::AuditLogger>,
2224
pub autofixer_service: Arc<AutofixerService>,
25+
pub secret_service: Arc<SecretService>,
2326
/// Docker client used by the preview gateway supervisor handlers.
2427
pub docker: Arc<bollard::Docker>,
2528
/// Platform settings service used by the preview gateway handlers to
@@ -33,5 +36,6 @@ pub fn configure_routes() -> Router<Arc<AppState>> {
3336
.merge(config::routes())
3437
.merge(preview_gateway::routes())
3538
.merge(runs::routes())
39+
.merge(secrets::routes())
3640
.merge(trigger::routes())
3741
}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
use axum::{
2+
extract::{Path, State},
3+
http::StatusCode,
4+
response::IntoResponse,
5+
routing::get,
6+
Extension, Json, Router,
7+
};
8+
use serde::{Deserialize, Serialize};
9+
use std::sync::Arc;
10+
use utoipa::ToSchema;
11+
12+
use temps_auth::{permission_guard, RequireAuth};
13+
use temps_core::audit::{AuditContext, AuditOperation};
14+
use temps_core::problemdetails::Problem;
15+
use temps_core::RequestMetadata;
16+
17+
use crate::handlers::AppState;
18+
use crate::services::secret_service::SecretType;
19+
20+
// ── Request / Response DTOs ──────────────────────────────────────────────────
21+
22+
#[derive(Debug, Deserialize, ToSchema)]
23+
pub struct UpsertSecretRequest {
24+
pub name: String,
25+
/// "env" (environment variable) or "file" (written to mount_path)
26+
#[serde(default = "default_env")]
27+
pub secret_type: String,
28+
pub value: String,
29+
/// Required for "file" type secrets — absolute path inside the sandbox
30+
pub mount_path: Option<String>,
31+
pub description: Option<String>,
32+
}
33+
34+
fn default_env() -> String {
35+
"env".to_string()
36+
}
37+
38+
#[derive(Debug, Serialize, ToSchema)]
39+
pub struct SecretResponse {
40+
pub id: i32,
41+
pub name: String,
42+
pub secret_type: String,
43+
/// Always masked in responses
44+
pub value: String,
45+
pub mount_path: Option<String>,
46+
pub description: Option<String>,
47+
pub created_at: String,
48+
pub updated_at: String,
49+
}
50+
51+
impl From<temps_entities::agent_secrets::Model> for SecretResponse {
52+
fn from(model: temps_entities::agent_secrets::Model) -> Self {
53+
Self {
54+
id: model.id,
55+
name: model.name,
56+
secret_type: model.secret_type,
57+
value: "***".to_string(),
58+
mount_path: model.mount_path,
59+
description: model.description,
60+
created_at: model.created_at.to_rfc3339(),
61+
updated_at: model.updated_at.to_rfc3339(),
62+
}
63+
}
64+
}
65+
66+
#[derive(Debug, Serialize, ToSchema)]
67+
pub struct ListSecretsResponse {
68+
pub items: Vec<SecretResponse>,
69+
pub total: usize,
70+
}
71+
72+
// ── Audit structs ────────────────────────────────────────────────────────────
73+
74+
#[derive(Debug, Clone, Serialize)]
75+
struct SecretUpsertedAudit {
76+
context: AuditContext,
77+
secret_name: String,
78+
}
79+
80+
#[derive(Debug, Clone, Serialize)]
81+
struct SecretDeletedAudit {
82+
context: AuditContext,
83+
secret_name: String,
84+
}
85+
86+
impl AuditOperation for SecretUpsertedAudit {
87+
fn operation_type(&self) -> String {
88+
"SECRET_UPSERTED".to_string()
89+
}
90+
fn user_id(&self) -> i32 {
91+
self.context.user_id
92+
}
93+
fn ip_address(&self) -> Option<String> {
94+
self.context.ip_address.clone()
95+
}
96+
fn user_agent(&self) -> &str {
97+
&self.context.user_agent
98+
}
99+
fn serialize(&self) -> temps_core::anyhow::Result<String> {
100+
serde_json::to_string(self)
101+
.map_err(|e| temps_core::anyhow::anyhow!("Failed to serialize audit: {}", e))
102+
}
103+
}
104+
105+
impl AuditOperation for SecretDeletedAudit {
106+
fn operation_type(&self) -> String {
107+
"SECRET_DELETED".to_string()
108+
}
109+
fn user_id(&self) -> i32 {
110+
self.context.user_id
111+
}
112+
fn ip_address(&self) -> Option<String> {
113+
self.context.ip_address.clone()
114+
}
115+
fn user_agent(&self) -> &str {
116+
&self.context.user_agent
117+
}
118+
fn serialize(&self) -> temps_core::anyhow::Result<String> {
119+
serde_json::to_string(self)
120+
.map_err(|e| temps_core::anyhow::anyhow!("Failed to serialize audit: {}", e))
121+
}
122+
}
123+
124+
// ── Routes ───────────────────────────────────────────────────────────────────
125+
126+
pub fn routes() -> Router<Arc<AppState>> {
127+
Router::new()
128+
.route("/settings/secrets", get(list_secrets).post(upsert_secret))
129+
.route(
130+
"/settings/secrets/{name}",
131+
axum::routing::delete(delete_secret),
132+
)
133+
}
134+
135+
// ── Handlers ─────────────────────────────────────────────────────────────────
136+
137+
#[utoipa::path(
138+
tag = "Secrets",
139+
get,
140+
path = "/settings/secrets",
141+
responses(
142+
(status = 200, description = "List of global agent secrets", body = ListSecretsResponse),
143+
(status = 401, description = "Unauthorized"),
144+
(status = 403, description = "Insufficient permissions"),
145+
),
146+
security(("bearer_auth" = []))
147+
)]
148+
async fn list_secrets(
149+
RequireAuth(auth): RequireAuth,
150+
State(app_state): State<Arc<AppState>>,
151+
) -> Result<impl IntoResponse, Problem> {
152+
permission_guard!(auth, SettingsRead);
153+
154+
let secrets = app_state
155+
.secret_service
156+
.list_secrets()
157+
.await
158+
.map_err(Problem::from)?;
159+
160+
let total = secrets.len();
161+
Ok(Json(ListSecretsResponse {
162+
items: secrets.into_iter().map(SecretResponse::from).collect(),
163+
total,
164+
}))
165+
}
166+
167+
#[utoipa::path(
168+
tag = "Secrets",
169+
post,
170+
path = "/settings/secrets",
171+
request_body = UpsertSecretRequest,
172+
responses(
173+
(status = 201, description = "Secret created/updated", body = SecretResponse),
174+
(status = 400, description = "Validation error"),
175+
(status = 401, description = "Unauthorized"),
176+
(status = 403, description = "Insufficient permissions"),
177+
),
178+
security(("bearer_auth" = []))
179+
)]
180+
async fn upsert_secret(
181+
RequireAuth(auth): RequireAuth,
182+
State(app_state): State<Arc<AppState>>,
183+
Extension(metadata): Extension<RequestMetadata>,
184+
Json(request): Json<UpsertSecretRequest>,
185+
) -> Result<impl IntoResponse, Problem> {
186+
permission_guard!(auth, SettingsWrite);
187+
188+
let secret_type = match request.secret_type.as_str() {
189+
"file" => SecretType::File,
190+
_ => SecretType::Env,
191+
};
192+
193+
let secret = app_state
194+
.secret_service
195+
.upsert_secret(
196+
&request.name,
197+
secret_type,
198+
&request.value,
199+
request.mount_path.as_deref(),
200+
request.description.as_deref(),
201+
)
202+
.await
203+
.map_err(Problem::from)?;
204+
205+
let audit = SecretUpsertedAudit {
206+
context: AuditContext {
207+
user_id: auth.user_id(),
208+
ip_address: Some(metadata.ip_address.clone()),
209+
user_agent: metadata.user_agent.clone(),
210+
},
211+
secret_name: secret.name.clone(),
212+
};
213+
if let Err(e) = app_state.audit_service.create_audit_log(&audit).await {
214+
tracing::error!(
215+
"Failed to create audit log for secret upsert (name {}): {}",
216+
secret.name,
217+
e
218+
);
219+
}
220+
221+
Ok((StatusCode::CREATED, Json(SecretResponse::from(secret))))
222+
}
223+
224+
#[utoipa::path(
225+
tag = "Secrets",
226+
delete,
227+
path = "/settings/secrets/{name}",
228+
params(
229+
("name" = String, Path, description = "Secret name"),
230+
),
231+
responses(
232+
(status = 204, description = "Secret deleted"),
233+
(status = 404, description = "Secret not found"),
234+
(status = 401, description = "Unauthorized"),
235+
(status = 403, description = "Insufficient permissions"),
236+
),
237+
security(("bearer_auth" = []))
238+
)]
239+
async fn delete_secret(
240+
RequireAuth(auth): RequireAuth,
241+
State(app_state): State<Arc<AppState>>,
242+
Extension(metadata): Extension<RequestMetadata>,
243+
Path(name): Path<String>,
244+
) -> Result<impl IntoResponse, Problem> {
245+
permission_guard!(auth, SettingsWrite);
246+
247+
app_state
248+
.secret_service
249+
.delete_secret(&name)
250+
.await
251+
.map_err(Problem::from)?;
252+
253+
let audit = SecretDeletedAudit {
254+
context: AuditContext {
255+
user_id: auth.user_id(),
256+
ip_address: Some(metadata.ip_address.clone()),
257+
user_agent: metadata.user_agent.clone(),
258+
},
259+
secret_name: name.clone(),
260+
};
261+
if let Err(e) = app_state.audit_service.create_audit_log(&audit).await {
262+
tracing::error!(
263+
"Failed to create audit log for secret delete (name {}): {}",
264+
&name,
265+
e
266+
);
267+
}
268+
269+
Ok(StatusCode::NO_CONTENT)
270+
}

0 commit comments

Comments
 (0)