mirror of
https://github.com/openai/codex.git
synced 2026-02-05 00:13:42 +00:00
Compare commits
14 Commits
dev/cc/tmp
...
etraut/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d681ed2f2b | ||
|
|
ddcb82c9b5 | ||
|
|
35567b3a0b | ||
|
|
73a1642fd4 | ||
|
|
ef60a312c5 | ||
|
|
5f8776d34d | ||
|
|
58a91a0b50 | ||
|
|
c29afc0cf3 | ||
|
|
cafb07fe6e | ||
|
|
07f077dfb3 | ||
|
|
7cf6f1c723 | ||
|
|
57f8158608 | ||
|
|
95580f229e | ||
|
|
896e9ed212 |
@@ -52,6 +52,12 @@ See `codex-rs/tui/styles.md`.
|
||||
- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic.
|
||||
- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils.
|
||||
|
||||
## Code
|
||||
|
||||
### Coding conventions
|
||||
|
||||
Keep functions relatively simple. Do not produce functions with dozens of return statements. Create helper functions if needed to keep code flow easy to understand.
|
||||
|
||||
## Tests
|
||||
|
||||
### Snapshot tests
|
||||
|
||||
@@ -2652,19 +2652,17 @@ impl CodexMessageProcessor {
|
||||
};
|
||||
|
||||
let skills_manager = self.conversation_manager.skills_manager();
|
||||
let data = cwds
|
||||
.into_iter()
|
||||
.map(|cwd| {
|
||||
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
|
||||
let errors = errors_to_info(&outcome.errors);
|
||||
let skills = skills_to_info(&outcome.skills);
|
||||
codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills,
|
||||
errors,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut data = Vec::new();
|
||||
for cwd in cwds {
|
||||
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
|
||||
let errors = errors_to_info(&outcome.errors);
|
||||
let skills = skills_to_info(&outcome.skills);
|
||||
data.push(codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
self.outgoing
|
||||
.send_response(request_id, SkillsListResponse { data })
|
||||
.await;
|
||||
|
||||
@@ -59,3 +59,61 @@ prefix_rule(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execpolicy_check_includes_justification_when_present() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
let policy_path = codex_home.path().join("rules").join("policy.rules");
|
||||
fs::create_dir_all(
|
||||
policy_path
|
||||
.parent()
|
||||
.expect("policy path should have a parent"),
|
||||
)?;
|
||||
fs::write(
|
||||
&policy_path,
|
||||
r#"
|
||||
prefix_rule(
|
||||
pattern = ["git", "push"],
|
||||
decision = "forbidden",
|
||||
justification = "pushing is blocked in this repo",
|
||||
)
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
||||
.env("CODEX_HOME", codex_home.path())
|
||||
.args([
|
||||
"execpolicy",
|
||||
"check",
|
||||
"--rules",
|
||||
policy_path
|
||||
.to_str()
|
||||
.expect("policy path should be valid UTF-8"),
|
||||
"git",
|
||||
"push",
|
||||
"origin",
|
||||
"main",
|
||||
])
|
||||
.output()?;
|
||||
|
||||
assert!(output.status.success());
|
||||
let result: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"decision": "forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["git", "push"],
|
||||
"decision": "forbidden",
|
||||
"justification": "pushing is blocked in this repo"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -100,6 +100,14 @@ impl From<RefreshTokenError> for std::io::Error {
|
||||
}
|
||||
|
||||
impl CodexAuth {
|
||||
/// Workspace/account identifier associated with this auth snapshot, derived from the cached
|
||||
/// ID token claims. This is used to prevent accidentally adopting credentials for a different
|
||||
/// identity when multiple Codex instances share a credential store.
|
||||
pub fn chatgpt_account_id(&self) -> Option<String> {
|
||||
self.get_current_token_data()
|
||||
.and_then(|t| t.id_token.chatgpt_account_id)
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&self) -> Result<String, RefreshTokenError> {
|
||||
tracing::info!("Refreshing token");
|
||||
|
||||
@@ -719,6 +727,165 @@ mod tests {
|
||||
assert_eq!(auth, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_from_storage_applies_updated_tokens_for_matching_identity() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let jwt = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
chatgpt_account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("seed auth");
|
||||
|
||||
let manager = AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
);
|
||||
let expected = manager.auth().expect("auth should be loaded");
|
||||
|
||||
let updated = AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: parse_id_token(&jwt).expect("jwt should parse"),
|
||||
access_token: "new-access".to_string(),
|
||||
refresh_token: "new-refresh".to_string(),
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
save_auth(codex_home.path(), &updated, AuthCredentialsStoreMode::File)
|
||||
.expect("write updated auth");
|
||||
|
||||
let sync = manager
|
||||
.sync_from_storage_for_request(&expected)
|
||||
.await
|
||||
.expect("sync should succeed");
|
||||
assert_eq!(sync, SyncFromStorageResult::Applied { changed: true });
|
||||
|
||||
let current = manager.auth().expect("auth should exist");
|
||||
let guard = current.auth_dot_json.lock().unwrap();
|
||||
let tokens = guard
|
||||
.as_ref()
|
||||
.and_then(|a| a.tokens.as_ref())
|
||||
.expect("tokens should exist");
|
||||
assert_eq!(tokens.access_token, "new-access");
|
||||
assert_eq!(tokens.refresh_token, "new-refresh");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_from_storage_returns_mismatch_for_different_identity() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let _jwt_expected = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
chatgpt_account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("seed auth");
|
||||
|
||||
let manager = AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
);
|
||||
let expected = manager.auth().expect("auth should be loaded");
|
||||
|
||||
let jwt_other = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
chatgpt_account_id: Some("acct_other".to_string()),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("seed other auth");
|
||||
|
||||
let mismatched = AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: parse_id_token(&jwt_other).expect("jwt should parse"),
|
||||
access_token: "other-access".to_string(),
|
||||
refresh_token: "other-refresh".to_string(),
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&mismatched,
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("write mismatched auth");
|
||||
|
||||
let sync = manager
|
||||
.sync_from_storage_for_request(&expected)
|
||||
.await
|
||||
.expect("sync should succeed");
|
||||
assert_eq!(sync, SyncFromStorageResult::IdentityMismatch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_from_storage_skips_when_identity_missing() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
chatgpt_account_id: None,
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("seed auth");
|
||||
|
||||
let manager = AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
);
|
||||
let expected = manager.auth().expect("auth should be loaded");
|
||||
|
||||
let sync = manager
|
||||
.sync_from_storage_for_request(&expected)
|
||||
.await
|
||||
.expect("sync should succeed");
|
||||
assert_eq!(sync, SyncFromStorageResult::SkippedMissingIdentity);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_from_storage_reports_logged_out_when_storage_empty() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
chatgpt_account_id: Some("acct_123".to_string()),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("seed auth");
|
||||
|
||||
let manager = AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
);
|
||||
let expected = manager.auth().expect("auth should be loaded");
|
||||
|
||||
std::fs::remove_file(codex_home.path().join("auth.json")).expect("remove auth");
|
||||
|
||||
let sync = manager
|
||||
.sync_from_storage_for_request(&expected)
|
||||
.await
|
||||
.expect("sync should succeed");
|
||||
assert_eq!(sync, SyncFromStorageResult::LoggedOut);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(codex_api_key)]
|
||||
async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
|
||||
@@ -1052,14 +1219,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Central manager providing a single source of truth for auth.json derived
|
||||
/// Central manager providing a single source of truth for auth storage derived
|
||||
/// authentication data. It loads once (or on preference change) and then
|
||||
/// hands out cloned `CodexAuth` values so the rest of the program has a
|
||||
/// consistent snapshot.
|
||||
///
|
||||
/// External modifications to `auth.json` will NOT be observed until
|
||||
/// `reload()` is called explicitly. This matches the design goal of avoiding
|
||||
/// different parts of the program seeing inconsistent auth data mid‑run.
|
||||
/// External modifications to credentials in storage will generally NOT be
|
||||
/// observed until `reload()` is called explicitly. One exception is the
|
||||
/// token-refresh recovery path, which may consult storage in order to adopt
|
||||
/// tokens rotated by another concurrently-running Codex instance.
|
||||
#[derive(Debug)]
|
||||
pub struct AuthManager {
|
||||
codex_home: PathBuf,
|
||||
@@ -1068,6 +1236,35 @@ pub struct AuthManager {
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
}
|
||||
|
||||
/// Outcome of attempting to sync the currently cached ChatGPT credentials from storage.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SyncFromStorageResult {
|
||||
/// The request auth did not include a workspace identifier, so we cannot safely compare.
|
||||
SkippedMissingIdentity,
|
||||
/// No credentials were found in storage (user logged out).
|
||||
LoggedOut,
|
||||
/// Storage contains credentials for a different identity (workspace/account).
|
||||
IdentityMismatch,
|
||||
/// Storage credentials match identity and were applied to the in-memory snapshot.
|
||||
Applied { changed: bool },
|
||||
}
|
||||
|
||||
/// Per-request state used to coordinate a limited 401 recovery flow.
|
||||
///
|
||||
/// This is intentionally managed by `AuthManager` so callers don't need to
|
||||
/// understand the recovery stages.
|
||||
#[derive(Default, Debug)]
|
||||
pub(crate) struct UnauthorizedRecovery {
|
||||
synced_from_storage: bool,
|
||||
refreshed_token: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum UnauthorizedRecoveryDecision {
|
||||
Retry,
|
||||
GiveUp,
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
/// Create a new manager loading the initial auth using the provided
|
||||
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
|
||||
@@ -1174,7 +1371,7 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
/// Attempt to refresh the current auth token (if any). On success, reload
|
||||
/// the auth state from disk so other components observe refreshed token.
|
||||
/// the auth state from storage so other components observe refreshed token.
|
||||
/// If the token refresh fails in a permanent (non‑transient) way, logs out
|
||||
/// to clear invalid auth state.
|
||||
pub async fn refresh_token(&self) -> Result<Option<String>, RefreshTokenError> {
|
||||
@@ -1182,17 +1379,8 @@ impl AuthManager {
|
||||
Some(a) => a,
|
||||
None => return Ok(None),
|
||||
};
|
||||
match auth.refresh_token().await {
|
||||
Ok(token) => {
|
||||
// Reload to pick up persisted changes.
|
||||
self.reload();
|
||||
Ok(Some(token))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to refresh token: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
self.refresh_token_for_request(&auth).await
|
||||
}
|
||||
|
||||
/// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true)
|
||||
@@ -1209,4 +1397,233 @@ impl AuthManager {
|
||||
pub fn get_auth_mode(&self) -> Option<AuthMode> {
|
||||
self.auth().map(|a| a.mode)
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_from_storage_for_request(
|
||||
&self,
|
||||
expected: &CodexAuth,
|
||||
) -> std::io::Result<SyncFromStorageResult> {
|
||||
let Some(expected_account_id) = expected.chatgpt_account_id() else {
|
||||
return Ok(SyncFromStorageResult::SkippedMissingIdentity);
|
||||
};
|
||||
|
||||
let storage =
|
||||
create_auth_storage(self.codex_home.clone(), self.auth_credentials_store_mode);
|
||||
let Some(stored) = load_auth_dot_json_with_retries(&storage).await? else {
|
||||
// Ensure the cached auth reflects the logged-out state.
|
||||
self.reload();
|
||||
return Ok(SyncFromStorageResult::LoggedOut);
|
||||
};
|
||||
|
||||
// We only support ChatGPT in this sync path. If storage now points to an API key or is
|
||||
// otherwise missing tokens, treat it as an identity mismatch for this request.
|
||||
let Some(tokens) = stored.tokens.clone() else {
|
||||
self.reload();
|
||||
return Ok(SyncFromStorageResult::IdentityMismatch);
|
||||
};
|
||||
|
||||
let Some(stored_account_id) = tokens.id_token.chatgpt_account_id.as_deref() else {
|
||||
// Cannot prove identity match.
|
||||
return Ok(SyncFromStorageResult::SkippedMissingIdentity);
|
||||
};
|
||||
|
||||
if stored_account_id != expected_account_id {
|
||||
// Keep cached auth in sync for subsequent requests, but do not retry the in-flight
|
||||
// request under a different identity.
|
||||
self.reload();
|
||||
return Ok(SyncFromStorageResult::IdentityMismatch);
|
||||
}
|
||||
|
||||
let changed = if let Some(current) = self.auth() {
|
||||
if let Ok(mut guard) = current.auth_dot_json.lock() {
|
||||
let current_auth = guard.clone();
|
||||
*guard = Some(stored.clone());
|
||||
current_auth != Some(stored)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(SyncFromStorageResult::Applied { changed })
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_token_for_request(
|
||||
&self,
|
||||
expected: &CodexAuth,
|
||||
) -> Result<Option<String>, RefreshTokenError> {
|
||||
if expected.mode != AuthMode::ChatGPT {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(auth) = self.auth() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let expected_account_id = expected.chatgpt_account_id();
|
||||
let Some(expected_account_id) = expected_account_id.as_deref() else {
|
||||
// Cannot safely consult storage without a stable identity; fall back to current
|
||||
// in-memory refresh behavior.
|
||||
let token = auth.refresh_token().await?;
|
||||
self.reload();
|
||||
return Ok(Some(token));
|
||||
};
|
||||
|
||||
let storage =
|
||||
create_auth_storage(self.codex_home.clone(), self.auth_credentials_store_mode);
|
||||
let Some(mut attempted_refresh_token) =
|
||||
load_stored_refresh_token_if_identity_matches(&storage, expected_account_id).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut retried = false;
|
||||
loop {
|
||||
let refresh_response =
|
||||
try_refresh_token(attempted_refresh_token.clone(), &auth.client).await;
|
||||
|
||||
match refresh_response {
|
||||
Ok(refresh_response) => {
|
||||
let updated = update_tokens(
|
||||
&storage,
|
||||
refresh_response.id_token,
|
||||
refresh_response.access_token,
|
||||
refresh_response.refresh_token,
|
||||
)
|
||||
.await
|
||||
.map_err(RefreshTokenError::from)?;
|
||||
|
||||
if let Ok(mut auth_lock) = auth.auth_dot_json.lock() {
|
||||
*auth_lock = Some(updated.clone());
|
||||
}
|
||||
self.reload();
|
||||
|
||||
let access = updated
|
||||
.tokens
|
||||
.as_ref()
|
||||
.map(|t| t.access_token.clone())
|
||||
.ok_or_else(|| {
|
||||
RefreshTokenError::other_with_message(
|
||||
"Token data is not available after refresh.",
|
||||
)
|
||||
})?;
|
||||
return Ok(Some(access));
|
||||
}
|
||||
Err(RefreshTokenError::Permanent(failed))
|
||||
if !retried
|
||||
&& matches!(
|
||||
failed.reason,
|
||||
RefreshTokenFailedReason::Expired | RefreshTokenFailedReason::Exhausted
|
||||
) =>
|
||||
{
|
||||
// Another instance may have refreshed and rotated the refresh token while we
|
||||
// were attempting our refresh. Reload and retry once if the stored refresh
|
||||
// token differs and identity still matches.
|
||||
let Some(stored_refresh_token) = load_stored_refresh_token_if_identity_matches(
|
||||
&storage,
|
||||
expected_account_id,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if stored_refresh_token != attempted_refresh_token {
|
||||
attempted_refresh_token = stored_refresh_token;
|
||||
retried = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(RefreshTokenError::Permanent(failed));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn recover_from_unauthorized_for_request(
|
||||
&self,
|
||||
expected: &CodexAuth,
|
||||
recovery: &mut UnauthorizedRecovery,
|
||||
) -> Result<UnauthorizedRecoveryDecision, RefreshTokenError> {
|
||||
if recovery.refreshed_token {
|
||||
return Ok(UnauthorizedRecoveryDecision::GiveUp);
|
||||
}
|
||||
|
||||
if expected.mode != AuthMode::ChatGPT {
|
||||
return Ok(UnauthorizedRecoveryDecision::GiveUp);
|
||||
}
|
||||
|
||||
if !recovery.synced_from_storage {
|
||||
let sync = self
|
||||
.sync_from_storage_for_request(expected)
|
||||
.await
|
||||
.map_err(RefreshTokenError::Transient)?;
|
||||
recovery.synced_from_storage = true;
|
||||
match sync {
|
||||
SyncFromStorageResult::Applied { changed } => {
|
||||
tracing::debug!(changed, "synced ChatGPT credentials from storage after 401");
|
||||
Ok(UnauthorizedRecoveryDecision::Retry)
|
||||
}
|
||||
SyncFromStorageResult::SkippedMissingIdentity => {
|
||||
Ok(UnauthorizedRecoveryDecision::Retry)
|
||||
}
|
||||
SyncFromStorageResult::LoggedOut | SyncFromStorageResult::IdentityMismatch => {
|
||||
Ok(UnauthorizedRecoveryDecision::GiveUp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self.refresh_token_for_request(expected).await? {
|
||||
Some(_) => {
|
||||
recovery.refreshed_token = true;
|
||||
Ok(UnauthorizedRecoveryDecision::Retry)
|
||||
}
|
||||
None => Ok(UnauthorizedRecoveryDecision::GiveUp),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_stored_refresh_token_if_identity_matches(
|
||||
storage: &Arc<dyn AuthStorageBackend>,
|
||||
expected_account_id: &str,
|
||||
) -> Result<Option<String>, RefreshTokenError> {
|
||||
let Some(stored) = load_auth_dot_json_with_retries(storage)
|
||||
.await
|
||||
.map_err(RefreshTokenError::Transient)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(tokens) = stored.tokens else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if tokens.id_token.chatgpt_account_id.as_deref() != Some(expected_account_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(tokens.refresh_token))
|
||||
}
|
||||
|
||||
async fn load_auth_dot_json_with_retries(
|
||||
storage: &Arc<dyn AuthStorageBackend>,
|
||||
) -> std::io::Result<Option<AuthDotJson>> {
|
||||
// This primarily mitigates concurrent file writes where another process truncates and rewrites
|
||||
// auth.json, which can cause transient JSON parse errors for readers.
|
||||
const MAX_ATTEMPTS: usize = 3;
|
||||
const BASE_DELAY_MS: u64 = 25;
|
||||
|
||||
for attempt in 0..MAX_ATTEMPTS {
|
||||
match storage.load() {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) if err.kind() == ErrorKind::InvalidData && attempt + 1 < MAX_ATTEMPTS => {
|
||||
let delay = Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1));
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use sha2::Sha256;
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
@@ -81,7 +82,8 @@ impl FileAuthStorage {
|
||||
let mut file = File::open(auth_file)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
|
||||
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)
|
||||
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?;
|
||||
|
||||
Ok(auth_dot_json)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use codex_api::TransportError;
|
||||
use codex_api::common::Reasoning;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_otel::otel_manager::OtelManager;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
@@ -153,7 +152,7 @@ impl ModelClient {
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
let session_source = self.session_source.clone();
|
||||
|
||||
let mut refreshed = false;
|
||||
let mut recovery = crate::auth::UnauthorizedRecovery::default();
|
||||
loop {
|
||||
let auth = auth_manager.as_ref().and_then(|m| m.auth());
|
||||
let api_provider = self
|
||||
@@ -179,7 +178,7 @@ impl ModelClient {
|
||||
Err(ApiError::Transport(TransportError::Http { status, .. }))
|
||||
if status == StatusCode::UNAUTHORIZED =>
|
||||
{
|
||||
handle_unauthorized(status, &mut refreshed, &auth_manager, &auth).await?;
|
||||
handle_unauthorized(status, &mut recovery, &auth_manager, &auth).await?;
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(map_api_error(err)),
|
||||
@@ -242,7 +241,7 @@ impl ModelClient {
|
||||
let conversation_id = self.conversation_id.to_string();
|
||||
let session_source = self.session_source.clone();
|
||||
|
||||
let mut refreshed = false;
|
||||
let mut recovery = crate::auth::UnauthorizedRecovery::default();
|
||||
loop {
|
||||
let auth = auth_manager.as_ref().and_then(|m| m.auth());
|
||||
let api_provider = self
|
||||
@@ -276,7 +275,7 @@ impl ModelClient {
|
||||
Err(ApiError::Transport(TransportError::Http { status, .. }))
|
||||
if status == StatusCode::UNAUTHORIZED =>
|
||||
{
|
||||
handle_unauthorized(status, &mut refreshed, &auth_manager, &auth).await?;
|
||||
handle_unauthorized(status, &mut recovery, &auth_manager, &auth).await?;
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(map_api_error(err)),
|
||||
@@ -485,22 +484,20 @@ where
|
||||
/// the mapped `CodexErr` is returned to the caller.
|
||||
async fn handle_unauthorized(
|
||||
status: StatusCode,
|
||||
refreshed: &mut bool,
|
||||
recovery: &mut crate::auth::UnauthorizedRecovery,
|
||||
auth_manager: &Option<Arc<AuthManager>>,
|
||||
auth: &Option<crate::auth::CodexAuth>,
|
||||
) -> Result<()> {
|
||||
if *refreshed {
|
||||
return Err(map_unauthorized_status(status));
|
||||
}
|
||||
|
||||
if let Some(manager) = auth_manager.as_ref()
|
||||
&& let Some(auth) = auth.as_ref()
|
||||
&& auth.mode == AuthMode::ChatGPT
|
||||
{
|
||||
match manager.refresh_token().await {
|
||||
Ok(_) => {
|
||||
*refreshed = true;
|
||||
Ok(())
|
||||
match manager
|
||||
.recover_from_unauthorized_for_request(auth, recovery)
|
||||
.await
|
||||
{
|
||||
Ok(crate::auth::UnauthorizedRecoveryDecision::Retry) => Ok(()),
|
||||
Ok(crate::auth::UnauthorizedRecoveryDecision::GiveUp) => {
|
||||
Err(map_unauthorized_status(status))
|
||||
}
|
||||
Err(RefreshTokenError::Permanent(failed)) => Err(CodexErr::RefreshTokenFailed(failed)),
|
||||
Err(RefreshTokenError::Transient(other)) => Err(CodexErr::Io(other)),
|
||||
|
||||
@@ -218,10 +218,11 @@ impl Codex {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let loaded_skills = config
|
||||
.features
|
||||
.enabled(Feature::Skills)
|
||||
.then(|| skills_manager.skills_for_cwd(&config.cwd));
|
||||
let loaded_skills = if config.features.enabled(Feature::Skills) {
|
||||
Some(skills_manager.skills_for_config(&config))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(outcome) = &loaded_skills {
|
||||
for err in &outcome.errors {
|
||||
@@ -1987,18 +1988,18 @@ mod handlers {
|
||||
};
|
||||
let skills = if sess.enabled(Feature::Skills) {
|
||||
let skills_manager = &sess.services.skills_manager;
|
||||
cwds.into_iter()
|
||||
.map(|cwd| {
|
||||
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills = super::skills_to_info(&outcome.skills);
|
||||
SkillsListEntry {
|
||||
cwd,
|
||||
skills,
|
||||
errors,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
let mut entries = Vec::new();
|
||||
for cwd in cwds {
|
||||
let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await;
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills = super::skills_to_info(&outcome.skills);
|
||||
entries.push(SkillsListEntry {
|
||||
cwd,
|
||||
skills,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
entries
|
||||
} else {
|
||||
cwds.into_iter()
|
||||
.map(|cwd| SkillsListEntry {
|
||||
@@ -2252,11 +2253,16 @@ pub(crate) async fn run_task(
|
||||
});
|
||||
sess.send_event(&turn_context, event).await;
|
||||
|
||||
let skills_outcome = sess.enabled(Feature::Skills).then(|| {
|
||||
sess.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&turn_context.cwd)
|
||||
});
|
||||
let skills_outcome = if sess.enabled(Feature::Skills) {
|
||||
Some(
|
||||
sess.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&turn_context.cwd, false)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let SkillInjections {
|
||||
items: skill_items,
|
||||
|
||||
@@ -28,11 +28,10 @@ use crate::features::Feature;
|
||||
use crate::features::Features;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use shlex::try_join as shlex_try_join;
|
||||
|
||||
const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
|
||||
const PROMPT_CONFLICT_REASON: &str =
|
||||
"execpolicy requires approval for this command, but AskForApproval is set to Never";
|
||||
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
|
||||
"approval required by policy, but AskForApproval is set to Never";
|
||||
const RULES_DIR_NAME: &str = "rules";
|
||||
const RULE_EXTENSION: &str = "rules";
|
||||
const DEFAULT_POLICY_FILE: &str = "default.rules";
|
||||
@@ -128,7 +127,7 @@ impl ExecPolicyManager {
|
||||
|
||||
match evaluation.decision {
|
||||
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
|
||||
reason: FORBIDDEN_REASON.to_string(),
|
||||
reason: derive_forbidden_reason(command, &evaluation),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
if matches!(approval_policy, AskForApproval::Never) {
|
||||
@@ -137,7 +136,7 @@ impl ExecPolicyManager {
|
||||
}
|
||||
} else {
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: derive_prompt_reason(&evaluation),
|
||||
reason: derive_prompt_reason(command, &evaluation),
|
||||
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
|
||||
try_derive_execpolicy_amendment_for_prompt_rules(
|
||||
&evaluation.matched_rules,
|
||||
@@ -299,15 +298,69 @@ fn try_derive_execpolicy_amendment_for_allow_rules(
|
||||
})
|
||||
}
|
||||
|
||||
/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision.
|
||||
fn derive_prompt_reason(evaluation: &Evaluation) -> Option<String> {
|
||||
evaluation.matched_rules.iter().find_map(|rule_match| {
|
||||
if is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt {
|
||||
Some(PROMPT_REASON.to_string())
|
||||
} else {
|
||||
None
|
||||
/// Only return a reason when a policy rule drove the prompt decision.
|
||||
fn derive_prompt_reason(command_args: &[String], evaluation: &Evaluation) -> Option<String> {
|
||||
let command = render_shlex_command(command_args);
|
||||
|
||||
let most_specific_prompt = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.filter_map(|rule_match| match rule_match {
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: Decision::Prompt,
|
||||
justification,
|
||||
..
|
||||
} => Some((matched_prefix.len(), justification.as_deref())),
|
||||
_ => None,
|
||||
})
|
||||
.max_by_key(|(matched_prefix_len, _)| *matched_prefix_len);
|
||||
|
||||
match most_specific_prompt {
|
||||
Some((_matched_prefix_len, Some(justification))) => {
|
||||
Some(format!("`{command}` requires approval: {justification}"))
|
||||
}
|
||||
})
|
||||
Some((_matched_prefix_len, None)) => {
|
||||
Some(format!("`{command}` requires approval by policy"))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_shlex_command(args: &[String]) -> String {
|
||||
shlex_try_join(args.iter().map(String::as_str)).unwrap_or_else(|_| args.join(" "))
|
||||
}
|
||||
|
||||
/// Derive a string explaining why the command was forbidden. If `justification`
|
||||
/// is set by the user, this can contain instructions with recommended
|
||||
/// alternatives, for example.
|
||||
fn derive_forbidden_reason(command_args: &[String], evaluation: &Evaluation) -> String {
|
||||
let command = render_shlex_command(command_args);
|
||||
|
||||
let most_specific_forbidden = evaluation
|
||||
.matched_rules
|
||||
.iter()
|
||||
.filter_map(|rule_match| match rule_match {
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: Decision::Forbidden,
|
||||
justification,
|
||||
..
|
||||
} => Some((matched_prefix, justification.as_deref())),
|
||||
_ => None,
|
||||
})
|
||||
.max_by_key(|(matched_prefix, _)| matched_prefix.len());
|
||||
|
||||
match most_specific_forbidden {
|
||||
Some((_matched_prefix, Some(justification))) => {
|
||||
format!("`{command}` rejected: {justification}")
|
||||
}
|
||||
Some((matched_prefix, None)) => {
|
||||
let prefix = render_shlex_command(matched_prefix);
|
||||
format!("`{command}` rejected: policy forbids commands starting with `{prefix}`")
|
||||
}
|
||||
None => format!("`{command}` rejected: blocked by policy"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_policy_files(dir: impl AsRef<Path>) -> Result<Vec<PathBuf>, ExecPolicyError> {
|
||||
@@ -450,7 +503,8 @@ mod tests {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["rm".to_string()],
|
||||
decision: Decision::Forbidden
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
policy.check_multiple(command.iter(), &|_| Decision::Allow)
|
||||
@@ -528,7 +582,8 @@ mod tests {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["rm".to_string()],
|
||||
decision: Decision::Forbidden
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
policy.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
|
||||
@@ -538,7 +593,8 @@ mod tests {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["ls".to_string()],
|
||||
decision: Decision::Prompt
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow)
|
||||
@@ -560,7 +616,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
let forbidden_script = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"rm -rf /tmp".to_string(),
|
||||
"rm -rf /some/important/folder".to_string(),
|
||||
];
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
@@ -577,7 +633,45 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: FORBIDDEN_REASON.to_string()
|
||||
reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn justification_is_included_in_forbidden_exec_approval_requirement() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern=["rm"],
|
||||
decision="forbidden",
|
||||
justification="destructive command",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(
|
||||
&Features::with_defaults(),
|
||||
&[
|
||||
"rm".to_string(),
|
||||
"-rf".to_string(),
|
||||
"/some/important/folder".to_string(),
|
||||
],
|
||||
AskForApproval::OnRequest,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
SandboxPermissions::UseDefault,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -606,7 +700,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(PROMPT_REASON.to_string()),
|
||||
reason: Some("`rm` requires approval by policy".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
@@ -824,7 +918,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some(PROMPT_REASON.to_string()),
|
||||
reason: Some("`rm` requires approval by policy".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -513,9 +513,9 @@ mod tests {
|
||||
)
|
||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let expected = format!(
|
||||
"base doc\n\n## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
|
||||
"base doc\n\n## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n### How to use skills\n{usage_rules}"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
@@ -537,9 +537,9 @@ mod tests {
|
||||
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
|
||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let expected = format!(
|
||||
"## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
|
||||
"## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- linting: run clippy (file: {expected_path_str})\n### How to use skills\n{usage_rules}"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::config::Config;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use crate::skills::system::system_cache_root_dir;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use serde::Deserialize;
|
||||
@@ -32,8 +33,6 @@ struct SkillFrontmatterMetadata {
|
||||
|
||||
const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
|
||||
const ADMIN_SKILLS_ROOT: &str = "/etc/codex/skills";
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
@@ -88,86 +87,81 @@ where
|
||||
.skills
|
||||
.retain(|skill| seen.insert(skill.name.clone()));
|
||||
|
||||
outcome
|
||||
.skills
|
||||
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: codex_home.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn system_skills_root(codex_home: &Path) -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: system_cache_root_dir(codex_home),
|
||||
scope: SkillScope::System,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn admin_skills_root() -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: PathBuf::from(ADMIN_SKILLS_ROOT),
|
||||
scope: SkillScope::Admin,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
|
||||
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
|
||||
let base = normalize_path(base).unwrap_or_else(|_| base.to_path_buf());
|
||||
|
||||
let repo_root =
|
||||
resolve_root_git_project_for_trust(&base).map(|root| normalize_path(&root).unwrap_or(root));
|
||||
|
||||
let scope = SkillScope::Repo;
|
||||
if let Some(repo_root) = repo_root.as_deref() {
|
||||
for dir in base.ancestors() {
|
||||
let skills_root = dir.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
if skills_root.is_dir() {
|
||||
return Some(SkillRoot {
|
||||
path: skills_root,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
if dir == repo_root {
|
||||
break;
|
||||
}
|
||||
fn scope_rank(scope: SkillScope) -> u8 {
|
||||
// Higher-priority scopes first (matches dedupe priority order).
|
||||
match scope {
|
||||
SkillScope::Repo => 0,
|
||||
SkillScope::User => 1,
|
||||
SkillScope::System => 2,
|
||||
SkillScope::Admin => 3,
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let skills_root = base.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
skills_root.is_dir().then_some(SkillRoot {
|
||||
path: skills_root,
|
||||
scope,
|
||||
})
|
||||
outcome.skills.sort_by(|a, b| {
|
||||
scope_rank(a.scope)
|
||||
.cmp(&scope_rank(b.scope))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn skill_roots_for_cwd(codex_home: &Path, cwd: &Path) -> Vec<SkillRoot> {
|
||||
fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) -> Vec<SkillRoot> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Some(repo_root) = repo_skills_root(cwd) {
|
||||
roots.push(repo_root);
|
||||
}
|
||||
for layer in config_layer_stack.layers_high_to_low() {
|
||||
let Some(config_folder) = layer.config_folder() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Load order matters: we dedupe by name, keeping the first occurrence.
|
||||
// Priority order: repo, user, system, then admin.
|
||||
roots.push(user_skills_root(codex_home));
|
||||
roots.push(system_skills_root(codex_home));
|
||||
if cfg!(unix) {
|
||||
roots.push(admin_skills_root());
|
||||
match &layer.name {
|
||||
ConfigLayerSource::Project { .. } => {
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.as_path().join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::User { .. } => {
|
||||
// `$CODEX_HOME/skills` (user-installed skills).
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.as_path().join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
});
|
||||
|
||||
// Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a
|
||||
// special case (not a config layer).
|
||||
roots.push(SkillRoot {
|
||||
path: system_cache_root_dir(config_folder.as_path()),
|
||||
scope: SkillScope::System,
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::System { .. } => {
|
||||
// The system config layer lives under `/etc/codex/` on Unix, so treat
|
||||
// `/etc/codex/skills` as admin-scoped skills.
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.as_path().join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Admin,
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
| ConfigLayerSource::SessionFlags
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {}
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
|
||||
skill_roots_for_cwd(&config.codex_home, &config.cwd)
|
||||
skill_roots_from_layer_stack_inner(&config.config_layer_stack)
|
||||
}
|
||||
|
||||
pub(crate) fn skill_roots_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> Vec<SkillRoot> {
|
||||
skill_roots_from_layer_stack_inner(config_layer_stack)
|
||||
}
|
||||
|
||||
fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) {
|
||||
@@ -318,21 +312,91 @@ fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
|
||||
|
||||
async fn make_config(codex_home: &TempDir) -> Config {
|
||||
let mut config = ConfigBuilder::default()
|
||||
make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await
|
||||
}
|
||||
|
||||
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config {
|
||||
let harness_overrides = ConfigOverrides {
|
||||
cwd: Some(cwd),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(harness_overrides)
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
.expect("defaults for test should always succeed")
|
||||
}
|
||||
|
||||
config.cwd = codex_home.path().to_path_buf();
|
||||
config
|
||||
fn mark_as_git_repo(dir: &Path) {
|
||||
// Config/project-root discovery only checks for the presence of `.git` (file or dir),
|
||||
// so we can avoid shelling out to `git init` in tests.
|
||||
fs::write(dir.join(".git"), "gitdir: fake\n").unwrap();
|
||||
}
|
||||
|
||||
fn normalized(path: &Path) -> PathBuf {
|
||||
normalize_path(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin()
|
||||
-> anyhow::Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
|
||||
let system_folder = tmp.path().join("etc/codex");
|
||||
let user_folder = tmp.path().join("home/codex");
|
||||
fs::create_dir_all(&system_folder)?;
|
||||
fs::create_dir_all(&user_folder)?;
|
||||
|
||||
// The file path doesn't need to exist; it's only used to derive the config folder.
|
||||
let system_file = AbsolutePathBuf::from_absolute_path(system_folder.join("config.toml"))?;
|
||||
let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?;
|
||||
|
||||
let layers = vec![
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::System { file: system_file },
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
),
|
||||
];
|
||||
let stack = ConfigLayerStack::new(layers, ConfigRequirements::default())?;
|
||||
|
||||
let got = skill_roots_from_layer_stack(&stack)
|
||||
.into_iter()
|
||||
.map(|root| (root.scope, root.path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(SkillScope::User, user_folder.join("skills")),
|
||||
(
|
||||
SkillScope::System,
|
||||
user_folder.join("skills").join(".system")
|
||||
),
|
||||
(SkillScope::Admin, system_folder.join("skills")),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
|
||||
@@ -368,7 +432,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn loads_valid_skill() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully");
|
||||
let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully");
|
||||
let cfg = make_config(&codex_home).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
@@ -377,15 +441,15 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
let skill = &outcome.skills[0];
|
||||
assert_eq!(skill.name, "demo-skill");
|
||||
assert_eq!(skill.description, "does things carefully");
|
||||
assert_eq!(skill.short_description, None);
|
||||
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
path_str.ends_with("skills/demo/SKILL.md"),
|
||||
"unexpected path {path_str}"
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "demo-skill".to_string(),
|
||||
description: "does things carefully".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,7 +459,8 @@ mod tests {
|
||||
let skill_dir = codex_home.path().join("skills/demo");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let contents = "---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: short summary\n---\n\n# Body\n";
|
||||
fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap();
|
||||
let skill_path = skill_dir.join(SKILLS_FILENAME);
|
||||
fs::write(&skill_path, contents).unwrap();
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
@@ -404,10 +469,15 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(
|
||||
outcome.skills[0].short_description,
|
||||
Some("short summary".to_string())
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "demo-skill".to_string(),
|
||||
description: "long description".to_string(),
|
||||
short_description: Some("short summary".to_string()),
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,22 +563,14 @@ mod tests {
|
||||
async fn loads_skills_from_repo_root() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let skills_root = repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME);
|
||||
write_skill_at(&skills_root, "repo", "repo-skill", "from repo");
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = repo_dir.path().to_path_buf();
|
||||
let repo_root = normalize_path(&skills_root).unwrap_or_else(|_| skills_root.clone());
|
||||
let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo");
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -516,28 +578,28 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
let skill = &outcome.skills[0];
|
||||
assert_eq!(skill.name, "repo-skill");
|
||||
assert!(skill.path.starts_with(&repo_root));
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "repo-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_skills_from_nearest_codex_dir_under_repo_root() {
|
||||
async fn loads_skills_from_all_codex_dirs_under_project_root() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let nested_dir = repo_dir.path().join("nested/inner");
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
write_skill_at(
|
||||
let root_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
@@ -546,7 +608,7 @@ mod tests {
|
||||
"root-skill",
|
||||
"from root",
|
||||
);
|
||||
write_skill_at(
|
||||
let nested_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join("nested")
|
||||
@@ -557,8 +619,7 @@ mod tests {
|
||||
"from nested",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = nested_dir;
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -566,8 +627,25 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "nested-skill");
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![
|
||||
SkillMetadata {
|
||||
name: "nested-skill".to_string(),
|
||||
description: "from nested".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&nested_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "root-skill".to_string(),
|
||||
description: "from root".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&root_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -575,7 +653,7 @@ mod tests {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let work_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_skill_at(
|
||||
let skill_path = write_skill_at(
|
||||
&work_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
@@ -585,8 +663,7 @@ mod tests {
|
||||
"from cwd",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = work_dir.path().to_path_buf();
|
||||
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -594,25 +671,26 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "local-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "local-skill".to_string(),
|
||||
description: "from cwd".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deduplicates_by_name_preferring_repo_over_user() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
write_skill_at(
|
||||
let _user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
let repo_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
@@ -622,8 +700,7 @@ mod tests {
|
||||
"from repo",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = repo_dir.path().to_path_buf();
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -631,17 +708,25 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&repo_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_system_skills_when_present() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
|
||||
write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
let _system_skill_path =
|
||||
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
|
||||
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
@@ -650,9 +735,16 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].description, "from user");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::User);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from user".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&user_skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -672,15 +764,9 @@ mod tests {
|
||||
"from outer",
|
||||
);
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&repo_dir)
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
mark_as_git_repo(&repo_dir);
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = repo_dir;
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -695,15 +781,9 @@ mod tests {
|
||||
async fn loads_skills_when_cwd_is_file_in_repo() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
write_skill_at(
|
||||
let skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
@@ -715,8 +795,7 @@ mod tests {
|
||||
let file_path = repo_dir.path().join("some-file.txt");
|
||||
fs::write(&file_path, "contents").unwrap();
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = file_path;
|
||||
let cfg = make_config_for_cwd(&codex_home, file_path).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -724,9 +803,16 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "repo-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "repo-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -746,8 +832,7 @@ mod tests {
|
||||
"from outer",
|
||||
);
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = nested_dir;
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -763,10 +848,9 @@ mod tests {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let work_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_system_skill(&codex_home, "system", "system-skill", "from system");
|
||||
let skill_path = write_system_skill(&codex_home, "system", "system-skill", "from system");
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = work_dir.path().to_path_buf();
|
||||
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -774,9 +858,16 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "system-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::System);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "system-skill".to_string(),
|
||||
description: "from system".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&skill_path),
|
||||
scope: SkillScope::System,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -800,8 +891,10 @@ mod tests {
|
||||
let system_dir = tempfile::tempdir().expect("tempdir");
|
||||
let admin_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_skill_at(system_dir.path(), "system", "dupe-skill", "from system");
|
||||
write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin");
|
||||
let system_skill_path =
|
||||
write_skill_at(system_dir.path(), "system", "dupe-skill", "from system");
|
||||
let _admin_skill_path =
|
||||
write_skill_at(admin_dir.path(), "admin", "dupe-skill", "from admin");
|
||||
|
||||
let outcome = load_skills_from_roots([
|
||||
SkillRoot {
|
||||
@@ -819,9 +912,16 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::System);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from system".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&system_skill_path),
|
||||
scope: SkillScope::System,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -829,11 +929,11 @@ mod tests {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let work_dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
|
||||
let user_skill_path = write_skill(&codex_home, "user", "dupe-skill", "from user");
|
||||
let _system_skill_path =
|
||||
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = work_dir.path().to_path_buf();
|
||||
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -841,24 +941,25 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::User);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from user".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&user_skill_path),
|
||||
scope: SkillScope::User,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deduplicates_by_name_preferring_repo_over_system() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(repo_dir.path())
|
||||
.status()
|
||||
.expect("git init");
|
||||
assert!(status.success(), "git init failed");
|
||||
|
||||
write_skill_at(
|
||||
let repo_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
@@ -867,10 +968,10 @@ mod tests {
|
||||
"dupe-skill",
|
||||
"from repo",
|
||||
);
|
||||
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
|
||||
let _system_skill_path =
|
||||
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
|
||||
|
||||
let mut cfg = make_config(&codex_home).await;
|
||||
cfg.cwd = repo_dir.path().to_path_buf();
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills(&cfg);
|
||||
assert!(
|
||||
@@ -878,8 +979,66 @@ mod tests {
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.skills[0].name, "dupe-skill");
|
||||
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
|
||||
assert_eq!(
|
||||
outcome.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from repo".to_string(),
|
||||
short_description: None,
|
||||
path: normalized(&repo_skill_path),
|
||||
scope: SkillScope::Repo,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deduplicates_by_name_preferring_nearest_project_codex_dir() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = tempfile::tempdir().expect("tempdir");
|
||||
mark_as_git_repo(repo_dir.path());
|
||||
|
||||
let nested_dir = repo_dir.path().join("nested/inner");
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
let _root_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"root",
|
||||
"dupe-skill",
|
||||
"from root",
|
||||
);
|
||||
let nested_skill_path = write_skill_at(
|
||||
&repo_dir
|
||||
.path()
|
||||
.join("nested")
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
"nested",
|
||||
"dupe-skill",
|
||||
"from nested",
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
let outcome = load_skills(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
let expected_path =
|
||||
normalize_path(&nested_skill_path).unwrap_or_else(|_| nested_skill_path.clone());
|
||||
assert_eq!(
|
||||
vec![SkillMetadata {
|
||||
name: "dupe-skill".to_string(),
|
||||
description: "from nested".to_string(),
|
||||
short_description: None,
|
||||
path: expected_path,
|
||||
scope: SkillScope::Repo,
|
||||
}],
|
||||
outcome.skills
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::skill_roots_for_cwd;
|
||||
use crate::skills::loader::skill_roots_from_layer_stack;
|
||||
use crate::skills::system::install_system_skills;
|
||||
|
||||
pub struct SkillsManager {
|
||||
codex_home: PathBuf,
|
||||
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
|
||||
@@ -24,11 +31,32 @@ impl SkillsManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
|
||||
self.skills_for_cwd_with_options(cwd, false)
|
||||
/// Load skills for an already-constructed [`Config`], avoiding any additional config-layer
|
||||
/// loading. This also seeds the per-cwd cache for subsequent lookups.
|
||||
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
|
||||
let cwd = &config.cwd;
|
||||
let cached = match self.cache_by_cwd.read() {
|
||||
Ok(cache) => cache.get(cwd).cloned(),
|
||||
Err(err) => err.into_inner().get(cwd).cloned(),
|
||||
};
|
||||
if let Some(outcome) = cached {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let roots = skill_roots_from_layer_stack(&config.config_layer_stack);
|
||||
let outcome = load_skills_from_roots(roots);
|
||||
match self.cache_by_cwd.write() {
|
||||
Ok(mut cache) => {
|
||||
cache.insert(cwd.to_path_buf(), outcome.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
err.into_inner().insert(cwd.to_path_buf(), outcome.clone());
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
pub fn skills_for_cwd_with_options(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
|
||||
pub async fn skills_for_cwd(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
|
||||
let cached = match self.cache_by_cwd.read() {
|
||||
Ok(cache) => cache.get(cwd).cloned(),
|
||||
Err(err) => err.into_inner().get(cwd).cloned(),
|
||||
@@ -37,7 +65,41 @@ impl SkillsManager {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let roots = skill_roots_for_cwd(&self.codex_home, cwd);
|
||||
let cwd_abs = match AbsolutePathBuf::try_from(cwd) {
|
||||
Ok(cwd_abs) => cwd_abs,
|
||||
Err(err) => {
|
||||
return SkillLoadOutcome {
|
||||
errors: vec![crate::skills::model::SkillError {
|
||||
path: cwd.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
|
||||
let config_layer_stack = match load_config_layers_state(
|
||||
&self.codex_home,
|
||||
Some(cwd_abs),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
return SkillLoadOutcome {
|
||||
errors: vec![crate::skills::model::SkillError {
|
||||
path: cwd.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let roots = skill_roots_from_layer_stack(&config_layer_stack);
|
||||
let outcome = load_skills_from_roots(roots);
|
||||
match self.cache_by_cwd.write() {
|
||||
Ok(mut cache) => {
|
||||
@@ -50,3 +112,52 @@ impl SkillsManager {
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) {
|
||||
let skill_dir = codex_home.path().join("skills").join(dir);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_seeds_cache_by_cwd() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let cfg = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
write_user_skill(&codex_home, "a", "skill-a", "from a");
|
||||
let outcome1 = skills_manager.skills_for_config(&cfg);
|
||||
assert!(
|
||||
outcome1.skills.iter().any(|s| s.name == "skill-a"),
|
||||
"expected skill-a to be discovered"
|
||||
);
|
||||
|
||||
// Write a new skill after the first call; the second call should hit the cache and not
|
||||
// reflect the new file.
|
||||
write_user_skill(&codex_home, "b", "skill-b", "from b");
|
||||
let outcome2 = skills_manager.skills_for_config(&cfg);
|
||||
assert_eq!(outcome2.errors, outcome1.errors);
|
||||
assert_eq!(outcome2.skills, outcome1.skills);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("These skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.".to_string());
|
||||
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
|
||||
lines.push("### Available skills".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
||||
@@ -16,22 +17,22 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
lines.push(format!("- {name}: {description} (file: {path_str})"));
|
||||
}
|
||||
|
||||
lines.push("### How to use skills".to_string());
|
||||
lines.push(
|
||||
r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
|
||||
- How to use a skill (progressive disclosure):
|
||||
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
|
||||
2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
|
||||
3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
|
||||
4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
|
||||
- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.
|
||||
- Coordination and sequencing:
|
||||
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
|
||||
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
|
||||
- Context hygiene:
|
||||
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
|
||||
- Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.
|
||||
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
|
||||
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
|
||||
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
|
||||
.to_string(),
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
|
||||
|
||||
assert!(
|
||||
end.aggregated_output
|
||||
.contains("execpolicy forbids this command"),
|
||||
.contains("policy forbids commands starting with `echo`"),
|
||||
"unexpected output: {}",
|
||||
end.aggregated_output
|
||||
);
|
||||
|
||||
@@ -11,11 +11,17 @@ pub struct Cli {
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Optional image(s) to attach to the initial prompt.
|
||||
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
|
||||
#[arg(
|
||||
long = "image",
|
||||
short = 'i',
|
||||
value_name = "FILE",
|
||||
value_delimiter = ',',
|
||||
num_args = 1..
|
||||
)]
|
||||
pub images: Vec<PathBuf>,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
#[arg(long, short = 'm', global = true)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Use open-source provider.
|
||||
@@ -37,7 +43,7 @@ pub struct Cli {
|
||||
pub config_profile: Option<String>,
|
||||
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write).
|
||||
#[arg(long = "full-auto", default_value_t = false)]
|
||||
#[arg(long = "full-auto", default_value_t = false, global = true)]
|
||||
pub full_auto: bool,
|
||||
|
||||
/// Skip all confirmation prompts and execute commands without sandboxing.
|
||||
@@ -46,6 +52,7 @@ pub struct Cli {
|
||||
long = "dangerously-bypass-approvals-and-sandbox",
|
||||
alias = "yolo",
|
||||
default_value_t = false,
|
||||
global = true,
|
||||
conflicts_with = "full_auto"
|
||||
)]
|
||||
pub dangerously_bypass_approvals_and_sandbox: bool,
|
||||
@@ -55,7 +62,7 @@ pub struct Cli {
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
#[arg(long = "skip-git-repo-check", global = true, default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Additional directories that should be writable alongside the primary workspace.
|
||||
@@ -74,7 +81,12 @@ pub struct Cli {
|
||||
pub color: Color,
|
||||
|
||||
/// Print events to stdout as JSONL.
|
||||
#[arg(long = "json", alias = "experimental-json", default_value_t = false)]
|
||||
#[arg(
|
||||
long = "json",
|
||||
alias = "experimental-json",
|
||||
default_value_t = false,
|
||||
global = true
|
||||
)]
|
||||
pub json: bool,
|
||||
|
||||
/// Specifies file where the last message from the agent should be written.
|
||||
@@ -107,6 +119,16 @@ pub struct ResumeArgs {
|
||||
#[arg(long = "last", default_value_t = false)]
|
||||
pub last: bool,
|
||||
|
||||
/// Optional image(s) to attach to the prompt sent after resuming.
|
||||
#[arg(
|
||||
long = "image",
|
||||
short = 'i',
|
||||
value_name = "FILE",
|
||||
value_delimiter = ',',
|
||||
num_args = 1
|
||||
)]
|
||||
pub images: Vec<PathBuf>,
|
||||
|
||||
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
@@ -335,6 +335,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
let prompt_text = resolve_prompt(prompt_arg);
|
||||
let mut items: Vec<UserInput> = imgs
|
||||
.into_iter()
|
||||
.chain(args.images.into_iter())
|
||||
.map(|path| UserInput::LocalImage { path })
|
||||
.collect();
|
||||
items.push(UserInput::Text {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
use anyhow::Context;
|
||||
use core_test_support::test_codex_exec::test_codex_exec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::string::ToString;
|
||||
@@ -69,6 +70,39 @@ fn extract_conversation_id(path: &std::path::Path) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn last_user_image_count(path: &std::path::Path) -> usize {
|
||||
let content = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let mut last_count = 0;
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(item): Result<Value, _> = serde_json::from_str(line) else {
|
||||
continue;
|
||||
};
|
||||
if item.get("type").and_then(|t| t.as_str()) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
let Some(payload) = item.get("payload") else {
|
||||
continue;
|
||||
};
|
||||
if payload.get("type").and_then(|t| t.as_str()) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
if payload.get("role").and_then(|r| r.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
let Some(content_items) = payload.get("content").and_then(|v| v.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
last_count = content_items
|
||||
.iter()
|
||||
.filter(|entry| entry.get("type").and_then(|t| t.as_str()) == Some("input_image"))
|
||||
.count();
|
||||
}
|
||||
last_count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
@@ -177,6 +211,41 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
let fixture =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
|
||||
|
||||
// Seed a session.
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("echo seed-resume-session")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Resume while passing global flags after the subcommand to ensure clap accepts them.
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("resume")
|
||||
.arg("--last")
|
||||
.arg("--json")
|
||||
.arg("--model")
|
||||
.arg("gpt-5.2-codex")
|
||||
.arg("--config")
|
||||
.arg("reasoning_level=xhigh")
|
||||
.arg("--dangerously-bypass-approvals-and-sandbox")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("echo resume-with-global-flags-after-subcommand")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
@@ -309,3 +378,64 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
|
||||
assert!(content.contains(&marker2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_accepts_images_after_subcommand() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
let fixture =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
|
||||
|
||||
let marker = format!("resume-image-{}", Uuid::new_v4());
|
||||
let prompt = format!("echo {marker}");
|
||||
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg(&prompt)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let image_path = test.cwd_path().join("resume_image.png");
|
||||
let image_path_2 = test.cwd_path().join("resume_image_2.png");
|
||||
let image_bytes: &[u8] = &[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
|
||||
0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00,
|
||||
0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
];
|
||||
std::fs::write(&image_path, image_bytes)?;
|
||||
std::fs::write(&image_path_2, image_bytes)?;
|
||||
|
||||
let marker2 = format!("resume-image-2-{}", Uuid::new_v4());
|
||||
let prompt2 = format!("echo {marker2}");
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg("resume")
|
||||
.arg("--last")
|
||||
.arg("--image")
|
||||
.arg(&image_path)
|
||||
.arg("--image")
|
||||
.arg(&image_path_2)
|
||||
.arg(&prompt2)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let sessions_dir = test.home_path().join("sessions");
|
||||
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
||||
.expect("no session file found after resume with images");
|
||||
let image_count = last_user_image_count(&resumed_path);
|
||||
assert_eq!(
|
||||
image_count, 2,
|
||||
"resume prompt should include both attached images"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,52 +1,65 @@
|
||||
# codex-execpolicy
|
||||
|
||||
## Overview
|
||||
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, match?, not_match?)`.
|
||||
|
||||
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)`.
|
||||
- This release covers the prefix-rule subset of the execpolicy language; a richer language will follow.
|
||||
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
|
||||
- `justification` is an optional human-readable rationale for why a rule exists. It can be provided for any `decision` and may be surfaced in different contexts (for example, in approval prompts or rejection messages). When `decision = "forbidden"` is used, include a recommended alternative in the `justification`, when appropriate (e.g., ``"Use `jj` instead of `git`."``).
|
||||
- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`).
|
||||
- The CLI always prints the JSON serialization of the evaluation result.
|
||||
- The legacy rule matcher lives in `codex-execpolicy-legacy`.
|
||||
|
||||
## Policy shapes
|
||||
|
||||
- Prefix rules use Starlark syntax:
|
||||
|
||||
```starlark
|
||||
prefix_rule(
|
||||
pattern = ["cmd", ["alt1", "alt2"]], # ordered tokens; list entries denote alternatives
|
||||
decision = "prompt", # allow | prompt | forbidden; defaults to allow
|
||||
justification = "explain why this rule exists",
|
||||
match = [["cmd", "alt1"], "cmd alt2"], # examples that must match this rule
|
||||
not_match = [["cmd", "oops"], "cmd alt3"], # examples that must not match this rule
|
||||
)
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
- From the Codex CLI, run `codex execpolicy check` subcommand with one or more policy files (for example `src/default.rules`) to check a command:
|
||||
|
||||
```bash
|
||||
codex execpolicy check --rules path/to/policy.rules git status
|
||||
```
|
||||
|
||||
- Pass multiple `--rules` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON.
|
||||
- You can also run the standalone dev binary directly during development:
|
||||
|
||||
```bash
|
||||
cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
|
||||
```
|
||||
|
||||
- Example outcomes:
|
||||
- Match: `{"matchedRules":[{...}],"decision":"allow"}`
|
||||
- No match: `{"matchedRules":[]}`
|
||||
|
||||
## Response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["<token>", "..."],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
"decision": "allow|prompt|forbidden",
|
||||
"justification": "..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
}
|
||||
```
|
||||
|
||||
- When no rules match, `matchedRules` is an empty array and `decision` is omitted.
|
||||
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
|
||||
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
prefix_rule(
|
||||
pattern = ["git", "reset", "--hard"],
|
||||
decision = "forbidden",
|
||||
justification = "destructive operation",
|
||||
match = [
|
||||
["git", "reset", "--hard"],
|
||||
],
|
||||
|
||||
@@ -11,6 +11,8 @@ pub enum Error {
|
||||
InvalidPattern(String),
|
||||
#[error("invalid example: {0}")]
|
||||
InvalidExample(String),
|
||||
#[error("invalid rule: {0}")]
|
||||
InvalidRule(String),
|
||||
#[error(
|
||||
"expected every example to match at least one rule. rules: {rules:?}; unmatched examples: \
|
||||
{examples:?}"
|
||||
|
||||
@@ -212,6 +212,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
decision: Option<&'v str>,
|
||||
r#match: Option<UnpackList<Value<'v>>>,
|
||||
not_match: Option<UnpackList<Value<'v>>>,
|
||||
justification: Option<&'v str>,
|
||||
eval: &mut Evaluator<'v, '_, '_>,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
let decision = match decision {
|
||||
@@ -219,6 +220,14 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
None => Decision::Allow,
|
||||
};
|
||||
|
||||
let justification = match justification {
|
||||
Some(raw) if raw.trim().is_empty() => {
|
||||
return Err(Error::InvalidRule("justification cannot be empty".to_string()).into());
|
||||
}
|
||||
Some(raw) => Some(raw.to_string()),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let pattern_tokens = parse_pattern(pattern)?;
|
||||
|
||||
let matches: Vec<Vec<String>> =
|
||||
@@ -246,6 +255,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
rest: rest.clone(),
|
||||
},
|
||||
decision,
|
||||
justification: justification.clone(),
|
||||
}) as RuleRef
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -46,6 +46,7 @@ impl Policy {
|
||||
.into(),
|
||||
},
|
||||
decision,
|
||||
justification: None,
|
||||
});
|
||||
|
||||
self.rules_by_program.insert(first_token.clone(), rule);
|
||||
|
||||
@@ -63,6 +63,12 @@ pub enum RuleMatch {
|
||||
#[serde(rename = "matchedPrefix")]
|
||||
matched_prefix: Vec<String>,
|
||||
decision: Decision,
|
||||
/// Optional rationale for why this rule exists.
|
||||
///
|
||||
/// This can be supplied for any decision and may be surfaced in different contexts
|
||||
/// (e.g., prompt reasons or rejection messages).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
justification: Option<String>,
|
||||
},
|
||||
HeuristicsRuleMatch {
|
||||
command: Vec<String>,
|
||||
@@ -83,6 +89,7 @@ impl RuleMatch {
|
||||
pub struct PrefixRule {
|
||||
pub pattern: PrefixPattern,
|
||||
pub decision: Decision,
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
pub trait Rule: Any + Debug + Send + Sync {
|
||||
@@ -104,6 +111,7 @@ impl Rule for PrefixRule {
|
||||
.map(|matched_prefix| RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: self.decision,
|
||||
justification: self.justification.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
@@ -71,6 +72,84 @@ prefix_rule(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justification_is_attached_to_forbidden_matches() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["rm"],
|
||||
decision = "forbidden",
|
||||
justification = "destructive command",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let evaluation = policy.check(
|
||||
&tokens(&["rm", "-rf", "/some/important/folder"]),
|
||||
&allow_all,
|
||||
);
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["rm"]),
|
||||
decision: Decision::Forbidden,
|
||||
justification: Some("destructive command".to_string()),
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justification_can_be_used_with_allow_decision() -> Result<()> {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["ls"],
|
||||
decision = "allow",
|
||||
justification = "safe and commonly used",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser.parse("test.rules", policy_src)?;
|
||||
let policy = parser.build();
|
||||
|
||||
let evaluation = policy.check(&tokens(&["ls", "-l"]), &prompt_all);
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["ls"]),
|
||||
decision: Decision::Allow,
|
||||
justification: Some("safe and commonly used".to_string()),
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justification_cannot_be_empty() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["ls"],
|
||||
decision = "prompt",
|
||||
justification = " ",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
let err = parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect_err("expected parse error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid rule: justification cannot be empty")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_prefix_rule_extends_policy() -> Result<()> {
|
||||
let mut policy = Policy::empty();
|
||||
@@ -84,17 +163,19 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
|
||||
rest: vec![PatternToken::Single(String::from("-l"))].into(),
|
||||
},
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
})],
|
||||
rules
|
||||
);
|
||||
|
||||
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all);
|
||||
let evaluation = policy.check(&tokens(&["ls", "-l", "/some/important/folder"]), &allow_all);
|
||||
assert_eq!(
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["ls", "-l"]),
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
@@ -142,6 +223,7 @@ prefix_rule(
|
||||
rest: Vec::<PatternToken>::new().into(),
|
||||
},
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
}),
|
||||
RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
@@ -149,6 +231,7 @@ prefix_rule(
|
||||
rest: vec![PatternToken::Single("commit".to_string())].into(),
|
||||
},
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
}),
|
||||
],
|
||||
git_rules
|
||||
@@ -161,6 +244,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
status_eval
|
||||
@@ -174,10 +258,12 @@ prefix_rule(
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -211,6 +297,7 @@ prefix_rule(
|
||||
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
|
||||
},
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
})],
|
||||
bash_rules
|
||||
);
|
||||
@@ -221,6 +308,7 @@ prefix_rule(
|
||||
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
|
||||
},
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
})],
|
||||
sh_rules
|
||||
);
|
||||
@@ -232,6 +320,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["bash", "-c"]),
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
bash_eval
|
||||
@@ -244,6 +333,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["sh", "-l"]),
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
sh_eval
|
||||
@@ -277,6 +367,7 @@ prefix_rule(
|
||||
.into(),
|
||||
},
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
})],
|
||||
rules
|
||||
);
|
||||
@@ -288,6 +379,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
npm_i
|
||||
@@ -303,6 +395,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "install", "--no-save"]),
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
npm_install
|
||||
@@ -332,6 +425,7 @@ prefix_rule(
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Allow,
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
match_eval
|
||||
@@ -378,10 +472,12 @@ prefix_rule(
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -419,14 +515,17 @@ prefix_rule(
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -137,26 +137,27 @@ async fn poll_for_token(
|
||||
}
|
||||
}
|
||||
|
||||
fn print_device_code_prompt(code: &str) {
|
||||
fn print_device_code_prompt(code: &str, issuer_base_url: &str) {
|
||||
println!(
|
||||
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
|
||||
\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
|
||||
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\
|
||||
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{issuer_base_url}/codex/device{ANSI_RESET}\n\
|
||||
\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\
|
||||
\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n",
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
code = code
|
||||
code = code,
|
||||
issuer_base_url = issuer_base_url
|
||||
);
|
||||
}
|
||||
|
||||
/// Full device code login flow.
|
||||
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
|
||||
let issuer_base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{issuer_base_url}/api/accounts");
|
||||
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
||||
|
||||
print_device_code_prompt(&uc.user_code);
|
||||
print_device_code_prompt(&uc.user_code, issuer_base_url);
|
||||
|
||||
let code_resp = poll_for_token(
|
||||
&client,
|
||||
@@ -171,10 +172,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
code_verifier: code_resp.code_verifier,
|
||||
code_challenge: code_resp.code_challenge,
|
||||
};
|
||||
let redirect_uri = format!("{base_url}/deviceauth/callback");
|
||||
let redirect_uri = format!("{issuer_base_url}/deviceauth/callback");
|
||||
|
||||
let tokens = crate::server::exchange_code_for_tokens(
|
||||
base_url,
|
||||
issuer_base_url,
|
||||
&opts.client_id,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
|
||||
@@ -279,6 +279,12 @@ impl AuthModeWidget {
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(vec![
|
||||
" On a remote or headless machine? Use ".into(),
|
||||
"codex login --device-auth".cyan(),
|
||||
" instead".into(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(" Press Esc to cancel".dim().into());
|
||||
|
||||
@@ -279,6 +279,12 @@ impl AuthModeWidget {
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(vec![
|
||||
" On a remote or headless machine? Use ".into(),
|
||||
"codex login --device-auth".cyan(),
|
||||
" instead".into(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(" Press Esc to cancel".dim().into());
|
||||
|
||||
@@ -30,7 +30,7 @@ const SKIP_DIR_SUFFIXES: &[&str] = &[
|
||||
"/programdata",
|
||||
];
|
||||
|
||||
fn normalize_path_key(p: &Path) -> String {
|
||||
pub(crate) fn normalize_path_key(p: &Path) -> String {
|
||||
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_windows_sandbox::create_process_as_user;
|
||||
use codex_windows_sandbox::create_readonly_token_with_cap_from;
|
||||
use codex_windows_sandbox::create_workspace_write_token_with_cap_from;
|
||||
use codex_windows_sandbox::get_current_token_for_restriction;
|
||||
use codex_windows_sandbox::hide_current_user_profile_dir;
|
||||
use codex_windows_sandbox::log_note;
|
||||
use codex_windows_sandbox::parse_policy;
|
||||
use codex_windows_sandbox::to_wide;
|
||||
@@ -91,6 +92,7 @@ pub fn main() -> Result<()> {
|
||||
}
|
||||
let req: RunnerRequest = serde_json::from_str(&input).context("parse runner request json")?;
|
||||
let log_dir = Some(req.codex_home.as_path());
|
||||
hide_current_user_profile_dir(req.codex_home.as_path());
|
||||
log_note(
|
||||
&format!(
|
||||
"runner start cwd={} cmd={:?} real_codex_home={}",
|
||||
|
||||
160
codex-rs/windows-sandbox-rs/src/hide_users.rs
Normal file
160
codex-rs/windows-sandbox-rs/src/hide_users.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
use crate::logging::log_note;
|
||||
use crate::winutil::format_last_error;
|
||||
use crate::winutil::to_wide;
|
||||
use anyhow::anyhow;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Storage::FileSystem::GetFileAttributesW;
|
||||
use windows_sys::Win32::Storage::FileSystem::SetFileAttributesW;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_HIDDEN;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_SYSTEM;
|
||||
use windows_sys::Win32::Storage::FileSystem::INVALID_FILE_ATTRIBUTES;
|
||||
use windows_sys::Win32::System::Registry::RegCloseKey;
|
||||
use windows_sys::Win32::System::Registry::RegCreateKeyExW;
|
||||
use windows_sys::Win32::System::Registry::RegSetValueExW;
|
||||
use windows_sys::Win32::System::Registry::HKEY;
|
||||
use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE;
|
||||
use windows_sys::Win32::System::Registry::KEY_WRITE;
|
||||
use windows_sys::Win32::System::Registry::REG_DWORD;
|
||||
use windows_sys::Win32::System::Registry::REG_OPTION_NON_VOLATILE;
|
||||
|
||||
const USERLIST_KEY_PATH: &str =
|
||||
r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList";
|
||||
|
||||
pub fn hide_newly_created_users(usernames: &[String], log_base: &Path) {
|
||||
if usernames.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = hide_users_in_winlogon(usernames, log_base) {
|
||||
log_note(
|
||||
&format!("hide users: failed to update Winlogon UserList: {err}"),
|
||||
Some(log_base),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: hides the current sandbox user's profile directory once it exists.
|
||||
///
|
||||
/// Windows only creates profile directories when that user first logs in.
|
||||
/// This intentionally runs in the command-runner (as the sandbox user) because
|
||||
/// command running is what causes us to log in as a particular sandbox user.
|
||||
pub fn hide_current_user_profile_dir(log_base: &Path) {
|
||||
let Some(profile) = std::env::var_os("USERPROFILE") else {
|
||||
return;
|
||||
};
|
||||
let profile_dir = PathBuf::from(profile);
|
||||
if !profile_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
match hide_directory(&profile_dir) {
|
||||
Ok(true) => {
|
||||
// Log only when we actually change attributes, so this stays one-time per profile dir.
|
||||
log_note(
|
||||
&format!(
|
||||
"hide users: profile dir hidden for current user ({})",
|
||||
profile_dir.display()
|
||||
),
|
||||
Some(log_base),
|
||||
);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
log_note(
|
||||
&format!(
|
||||
"hide users: failed to hide current user profile dir ({}): {err}",
|
||||
profile_dir.display()
|
||||
),
|
||||
Some(log_base),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_users_in_winlogon(usernames: &[String], log_base: &Path) -> anyhow::Result<()> {
|
||||
let key = create_userlist_key()?;
|
||||
for username in usernames {
|
||||
let name_w = to_wide(OsStr::new(username));
|
||||
let value: u32 = 0;
|
||||
let status = unsafe {
|
||||
RegSetValueExW(
|
||||
key,
|
||||
name_w.as_ptr(),
|
||||
0,
|
||||
REG_DWORD,
|
||||
&value as *const u32 as *const u8,
|
||||
std::mem::size_of_val(&value) as u32,
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
log_note(
|
||||
&format!(
|
||||
"hide users: failed to set UserList value for {username}: {status} ({error})",
|
||||
error = format_last_error(status as i32)
|
||||
),
|
||||
Some(log_base),
|
||||
);
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
RegCloseKey(key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_userlist_key() -> anyhow::Result<HKEY> {
|
||||
let key_path = to_wide(USERLIST_KEY_PATH);
|
||||
let mut key: HKEY = 0;
|
||||
let status = unsafe {
|
||||
RegCreateKeyExW(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
key_path.as_ptr(),
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
&mut key,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
return Err(anyhow!(
|
||||
"RegCreateKeyExW failed: {status} ({error})",
|
||||
error = format_last_error(status as i32)
|
||||
));
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Sets HIDDEN|SYSTEM on `path` if needed, returning whether it changed anything.
|
||||
fn hide_directory(path: &Path) -> anyhow::Result<bool> {
|
||||
let wide = to_wide(path);
|
||||
let attrs = unsafe { GetFileAttributesW(wide.as_ptr()) };
|
||||
if attrs == INVALID_FILE_ATTRIBUTES {
|
||||
let err = unsafe { GetLastError() } as i32;
|
||||
return Err(anyhow!(
|
||||
"GetFileAttributesW failed for {}: {err} ({error})",
|
||||
path.display(),
|
||||
error = format_last_error(err)
|
||||
));
|
||||
}
|
||||
let new_attrs = attrs | FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM;
|
||||
if new_attrs == attrs {
|
||||
return Ok(false);
|
||||
}
|
||||
let ok = unsafe { SetFileAttributesW(wide.as_ptr(), new_attrs) };
|
||||
if ok == 0 {
|
||||
let err = unsafe { GetLastError() } as i32;
|
||||
return Err(anyhow!(
|
||||
"SetFileAttributesW failed for {}: {err} ({error})",
|
||||
path.display(),
|
||||
error = format_last_error(err)
|
||||
));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -119,9 +119,10 @@ pub fn require_logon_sandbox_creds(
|
||||
) -> Result<SandboxCreds> {
|
||||
let sandbox_dir = crate::setup::sandbox_dir(codex_home);
|
||||
let needed_read = gather_read_roots(command_cwd, policy);
|
||||
let mut needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
|
||||
// Ensure the sandbox directory itself is writable by sandbox users.
|
||||
needed_write.push(sandbox_dir.clone());
|
||||
let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
|
||||
// NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the
|
||||
// restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for
|
||||
// granting the sandbox group access to this directory without granting the capability SID.
|
||||
let mut setup_reason: Option<String> = None;
|
||||
let mut _existing_marker: Option<SetupMarker> = None;
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ macro_rules! windows_modules {
|
||||
}
|
||||
|
||||
windows_modules!(
|
||||
acl, allow, audit, cap, dpapi, env, identity, logging, policy, process, token, winutil
|
||||
acl, allow, audit, cap, dpapi, env, hide_users, identity, logging, policy, process, token,
|
||||
winutil
|
||||
);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -38,6 +39,10 @@ pub use dpapi::unprotect as dpapi_unprotect;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use hide_users::hide_current_user_profile_dir;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use hide_users::hide_newly_created_users;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use identity::require_logon_sandbox_creds;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use logging::log_note;
|
||||
|
||||
@@ -9,6 +9,7 @@ use base64::Engine;
|
||||
use codex_windows_sandbox::convert_string_sid_to_sid;
|
||||
use codex_windows_sandbox::ensure_allow_mask_aces_with_inheritance;
|
||||
use codex_windows_sandbox::ensure_allow_write_aces;
|
||||
use codex_windows_sandbox::hide_newly_created_users;
|
||||
use codex_windows_sandbox::load_or_create_cap_sids;
|
||||
use codex_windows_sandbox::log_note;
|
||||
use codex_windows_sandbox::path_mask_allows;
|
||||
@@ -448,6 +449,11 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
|
||||
&payload.online_username,
|
||||
log,
|
||||
)?;
|
||||
let users = vec![
|
||||
payload.offline_username.clone(),
|
||||
payload.online_username.clone(),
|
||||
];
|
||||
hide_newly_created_users(&users, sbx_dir);
|
||||
}
|
||||
let offline_sid = resolve_sid(&payload.offline_username)?;
|
||||
let offline_sid_str = string_from_sid_bytes(&offline_sid).map_err(anyhow::Error::msg)?;
|
||||
|
||||
@@ -408,15 +408,12 @@ fn build_payload_roots(
|
||||
read_roots_override: Option<Vec<PathBuf>>,
|
||||
write_roots_override: Option<Vec<PathBuf>>,
|
||||
) -> (Vec<PathBuf>, Vec<PathBuf>) {
|
||||
let sbx_dir = sandbox_dir(codex_home);
|
||||
let mut write_roots = if let Some(roots) = write_roots_override {
|
||||
let write_roots = if let Some(roots) = write_roots_override {
|
||||
canonical_existing(&roots)
|
||||
} else {
|
||||
gather_write_roots(policy, policy_cwd, command_cwd, env_map)
|
||||
};
|
||||
if !write_roots.contains(&sbx_dir) {
|
||||
write_roots.push(sbx_dir.clone());
|
||||
}
|
||||
let write_roots = filter_sensitive_write_roots(write_roots, codex_home);
|
||||
let mut read_roots = if let Some(roots) = read_roots_override {
|
||||
canonical_existing(&roots)
|
||||
} else {
|
||||
@@ -426,3 +423,17 @@ fn build_payload_roots(
|
||||
read_roots.retain(|root| !write_root_set.contains(root));
|
||||
(read_roots, write_roots)
|
||||
}
|
||||
|
||||
fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> Vec<PathBuf> {
|
||||
// Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox.
|
||||
// These locations contain sandbox control/state and must remain tamper-resistant.
|
||||
let codex_home_key = crate::audit::normalize_path_key(codex_home);
|
||||
let sbx_dir_key = crate::audit::normalize_path_key(&sandbox_dir(codex_home));
|
||||
let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/'));
|
||||
|
||||
roots.retain(|root| {
|
||||
let key = crate::audit::normalize_path_key(root);
|
||||
key != codex_home_key && key != sbx_dir_key && !key.starts_with(&sbx_dir_prefix)
|
||||
});
|
||||
roots
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user