Synced from monorepo
Synced from monorepo Changes: - Report invalid MCP server config instead of failing startup - Keep completed terminal output when the gateway connection is lost - Show a duration-only detail view for single-task task output - Don't let a stale registry turn counter hide local sessions - Raise the file-descriptor soft limit on Linux and log effective limits at startup - Stop aborting when HTTP client construction fails - Make session thread and runtime spawn failures recoverable - Fix main-prompt paste parity in the question freeform input - Fire SessionEnd hooks on /exit and headless quit - Embed the deployment-config signing public key - Repaint paste-chip background on inline panel inputs - Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root - Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker - Parse hooks from config files - Add a remote kill-switch for managed-config signature verification - Security: fix workspace file-reference resolution bypassing workspace filesystem confinement Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -1,7 +1,5 @@
|
|||
//! Workspace error types.
|
||||
|
||||
use crate::capability::CapabilityMode;
|
||||
|
||||
/// Errors surfaced by the workspace public API.
|
||||
///
|
||||
/// `#[non_exhaustive]` so adding new variants is a non-breaking change.
|
||||
|
|
@ -11,68 +9,48 @@ use crate::capability::CapabilityMode;
|
|||
pub enum WorkspaceError {
|
||||
#[error("parent session not found: {0}")]
|
||||
ParentSessionNotFound(String),
|
||||
|
||||
#[error("session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
|
||||
#[error("session already exists: {0}")]
|
||||
SessionAlreadyExists(String),
|
||||
|
||||
#[error("agent_id must be non-empty")]
|
||||
EmptyAgentId,
|
||||
|
||||
#[error("the main session cannot be dropped")]
|
||||
CannotDropMainSession,
|
||||
|
||||
#[error("toolset finalization failed: {0}")]
|
||||
Finalize(String),
|
||||
|
||||
#[error("capability widening rejected: child {child:?} is not a subset of parent {parent:?}")]
|
||||
CapabilityWidening {
|
||||
parent: CapabilityMode,
|
||||
child: CapabilityMode,
|
||||
},
|
||||
|
||||
#[error("session {caller:?} is not authorised to operate on session {target:?}")]
|
||||
Unauthorized { caller: String, target: String },
|
||||
|
||||
/// A toolset mutation was rejected because the target session has an
|
||||
/// active turn. Retryable at the turn boundary (`after_turn`).
|
||||
#[error("turn active for session {0}; retry the tool-config update at the turn boundary")]
|
||||
TurnActive(String),
|
||||
|
||||
#[error("maximum fork depth exceeded for parent session {parent:?}")]
|
||||
MaxDepthExceeded { parent: String },
|
||||
|
||||
#[error("internal task failure: {0}")]
|
||||
JoinError(String),
|
||||
|
||||
#[error("invalid hunk action: {0}")]
|
||||
InvalidHunkAction(String),
|
||||
|
||||
#[error("hunk action failed: {0}")]
|
||||
HunkActionFailed(String),
|
||||
|
||||
/// An error from the server connection or tool server.
|
||||
#[error("hub error: {0}")]
|
||||
HubError(String),
|
||||
|
||||
/// Deploy-service error tagged with its gRPC status class; see
|
||||
/// [`DeployError`] for how the class crosses the workspace RPC boundary.
|
||||
///
|
||||
/// [`DeployError`]: xai_grok_workspace_types::rpc::deploy::DeployError
|
||||
#[error("deploy error: {message}")]
|
||||
DeployError {
|
||||
kind: xai_grok_workspace_types::rpc::deploy::DeployError,
|
||||
#[error("github export error: {message}")]
|
||||
ExportGithub {
|
||||
kind: xai_grok_workspace_types::rpc::export_github::ExportGithubError,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The workspace is draining/shutting down and is no longer accepting new
|
||||
/// sessions. Surfaced when a `bind`/create races a terminal drain so the
|
||||
/// shared upload queue is never torn down out from under a fresh session.
|
||||
#[error("workspace is shutting down; not accepting new sessions")]
|
||||
ShuttingDown,
|
||||
|
||||
/// The session's toolset is externally owned — installed by a local
|
||||
/// (shell) bind, its `Terminal` resource is not the session-owned
|
||||
/// backend — so an RPC-driven toolset mutation is refused instead of
|
||||
|
|
@ -81,7 +59,6 @@ pub enum WorkspaceError {
|
|||
#[error("toolset externally owned (local bind), mutation refused: {0}")]
|
||||
ToolsetExternallyOwned(String),
|
||||
}
|
||||
|
||||
impl WorkspaceError {
|
||||
/// Low-cardinality `error_kind` metric label: the variant name in
|
||||
/// snake_case; `DeployError` reports its per-kind `wire_code()`.
|
||||
|
|
@ -101,32 +78,17 @@ impl WorkspaceError {
|
|||
Self::InvalidHunkAction(_) => "invalid_hunk_action",
|
||||
Self::HunkActionFailed(_) => "hunk_action_failed",
|
||||
Self::HubError(_) => "hub_error",
|
||||
Self::DeployError { kind, .. } => kind.wire_code(),
|
||||
Self::ExportGithub { kind, .. } => kind.wire_code(),
|
||||
Self::ShuttingDown => "shutting_down",
|
||||
Self::ToolsetExternallyOwned(_) => "toolset_externally_owned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for the workspace's primary `Result` type.
|
||||
pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::WorkspaceError;
|
||||
use xai_grok_workspace_types::rpc::deploy::DeployError;
|
||||
|
||||
#[test]
|
||||
fn metric_kind_reports_deploy_wire_code() {
|
||||
for kind in DeployError::ALL {
|
||||
let err = WorkspaceError::DeployError {
|
||||
kind,
|
||||
message: "m".into(),
|
||||
};
|
||||
assert_eq!(err.metric_kind(), kind.wire_code());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_kind_is_message_free() {
|
||||
let err = WorkspaceError::HubError("something wildly unique 12345".into());
|
||||
|
|
|
|||
753
crates/codegen/xai-grok-workspace/src/export_github.rs
Normal file
753
crates/codegen/xai-grok-workspace/src/export_github.rs
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use xai_grok_workspace_types::rpc::export_github::{ExportGithubError, ExportGithubResponse};
|
||||
|
||||
pub const GITHUB_REPO_MAPPING_FILE: &str = ".github_repo";
|
||||
|
||||
const EXPORT_BUDGET: Duration = Duration::from_secs(120);
|
||||
|
||||
const DEFAULT_BRANCH: &str = "main";
|
||||
const AUTHOR_NAME: &str = "Grok";
|
||||
const AUTHOR_EMAIL: &str = "grok-export@users.noreply.github.com";
|
||||
|
||||
const SEED_GITIGNORE: &str = "node_modules/\n.project_id\n.github_repo\n.env\n.env.*\n";
|
||||
|
||||
pub struct ExportGithubParams<'a> {
|
||||
pub project_dir: &'a Path,
|
||||
pub repo_full_name: Option<&'a str>,
|
||||
pub remote_url_base: &'a str,
|
||||
pub web_url_base: &'a str,
|
||||
pub branch: Option<&'a str>,
|
||||
pub commit_message: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExportGithubFailure {
|
||||
pub kind: ExportGithubError,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ExportGithubFailure {
|
||||
fn new(kind: ExportGithubError, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn validate_repo_full_name(name: &str) -> Result<(), ExportGithubFailure> {
|
||||
let parts: Vec<&str> = name.split('/').collect();
|
||||
let valid_segment = |s: &str| {
|
||||
!s.is_empty()
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||
};
|
||||
if parts.len() == 2 && parts.iter().all(|p| valid_segment(p)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ExportGithubFailure::new(
|
||||
ExportGithubError::InvalidRepoName,
|
||||
format!("repository must be 'owner/name', got {name:?}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_export(
|
||||
params: ExportGithubParams<'_>,
|
||||
) -> Result<ExportGithubResponse, ExportGithubFailure> {
|
||||
let dir = params.project_dir.to_owned();
|
||||
match tokio::time::timeout(EXPORT_BUDGET, run_export_inner(params)).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
remove_stale_git_locks(&dir);
|
||||
Err(ExportGithubFailure::new(
|
||||
ExportGithubError::Timeout,
|
||||
format!("export exceeded {}s", EXPORT_BUDGET.as_secs()),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_stale_git_locks(dir: &Path) {
|
||||
for lock in ["index.lock", "HEAD.lock", "config.lock"] {
|
||||
let path = dir.join(".git").join(lock);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {
|
||||
tracing::warn!(lock = %path.display(), "removed stale git lock after timeout");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(lock = %path.display(), error = %e, "failed to remove git lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_export_inner(
|
||||
params: ExportGithubParams<'_>,
|
||||
) -> Result<ExportGithubResponse, ExportGithubFailure> {
|
||||
let dir = params.project_dir;
|
||||
if !dir.is_dir() {
|
||||
return Err(ExportGithubFailure::new(
|
||||
ExportGithubError::ProjectDirInvalid,
|
||||
format!("project dir does not exist: {}", dir.display()),
|
||||
));
|
||||
}
|
||||
|
||||
let repo_full_name = resolve_repo_mapping(dir, non_empty(params.repo_full_name))?;
|
||||
let remote_url = format!(
|
||||
"{}/{}.git",
|
||||
params.remote_url_base.trim_end_matches('/'),
|
||||
repo_full_name
|
||||
);
|
||||
|
||||
ensure_repo(dir, non_empty(params.branch)).await?;
|
||||
seed_gitignore_if_absent(dir)?;
|
||||
ensure_export_excludes(dir)?;
|
||||
untrack_excluded_paths(dir).await?;
|
||||
|
||||
git(dir, &["add", "-A"]).await?;
|
||||
let no_changes = commit_if_dirty(dir, non_empty(params.commit_message)).await?;
|
||||
let branch = current_branch(dir).await?;
|
||||
let commit_sha = match git(dir, &["rev-parse", "HEAD"]).await {
|
||||
Ok(sha) => sha,
|
||||
Err(_) if no_changes => {
|
||||
return Err(ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
"repository has no commits and no changes to export",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
set_remote(dir, &remote_url).await?;
|
||||
push(dir, &branch).await?;
|
||||
|
||||
Ok(ExportGithubResponse {
|
||||
repo_url: format!(
|
||||
"{}/{}",
|
||||
params.web_url_base.trim_end_matches('/'),
|
||||
repo_full_name
|
||||
),
|
||||
repo_full_name,
|
||||
branch,
|
||||
commit_sha,
|
||||
no_changes,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_repo_mapping(
|
||||
dir: &Path,
|
||||
requested: Option<&str>,
|
||||
) -> Result<String, ExportGithubFailure> {
|
||||
let mapping_path = dir.join(GITHUB_REPO_MAPPING_FILE);
|
||||
let stored = std::fs::read_to_string(&mapping_path)
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_owned()))
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let repo = match (requested, stored) {
|
||||
(Some(req), _) => req.to_owned(),
|
||||
(None, Some(stored)) => stored,
|
||||
(None, None) => {
|
||||
return Err(ExportGithubFailure::new(
|
||||
ExportGithubError::RepoNotSpecified,
|
||||
"no repository named and no .github_repo mapping exists",
|
||||
));
|
||||
}
|
||||
};
|
||||
validate_repo_full_name(&repo)?;
|
||||
|
||||
std::fs::write(&mapping_path, format!("{repo}\n")).map_err(|e| {
|
||||
ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
format!("writing {GITHUB_REPO_MAPPING_FILE}: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
async fn ensure_repo(dir: &Path, branch: Option<&str>) -> Result<(), ExportGithubFailure> {
|
||||
if !dir.join(".git").exists() {
|
||||
git(dir, &["init", "-b", branch.unwrap_or(DEFAULT_BRANCH)]).await?;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(requested) = branch else {
|
||||
return Ok(());
|
||||
};
|
||||
if git(dir, &["rev-parse", "--verify", "HEAD"]).await.is_ok() {
|
||||
git(dir, &["checkout", "-B", requested]).await?;
|
||||
} else {
|
||||
let target = format!("refs/heads/{requested}");
|
||||
git(dir, &["symbolic-ref", "HEAD", &target]).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seed_gitignore_if_absent(dir: &Path) -> Result<(), ExportGithubFailure> {
|
||||
let path = dir.join(".gitignore");
|
||||
if path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(&path, SEED_GITIGNORE).map_err(|e| {
|
||||
ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
format!("seeding .gitignore: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_export_excludes(dir: &Path) -> Result<(), ExportGithubFailure> {
|
||||
let io_err = |e: std::io::Error| {
|
||||
ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
format!("writing .git/info/exclude: {e}"),
|
||||
)
|
||||
};
|
||||
let info_dir = dir.join(".git").join("info");
|
||||
std::fs::create_dir_all(&info_dir).map_err(io_err)?;
|
||||
let exclude_path = info_dir.join("exclude");
|
||||
let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default();
|
||||
let missing: Vec<&str> = SEED_GITIGNORE
|
||||
.lines()
|
||||
.filter(|rule| !existing.lines().any(|line| line.trim() == *rule))
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let separator = if existing.is_empty() || existing.ends_with('\n') {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
std::fs::write(
|
||||
&exclude_path,
|
||||
format!("{existing}{separator}{}\n", missing.join("\n")),
|
||||
)
|
||||
.map_err(io_err)
|
||||
}
|
||||
|
||||
async fn untrack_excluded_paths(dir: &Path) -> Result<(), ExportGithubFailure> {
|
||||
let mut args = vec![
|
||||
"rm",
|
||||
"-r",
|
||||
"-f",
|
||||
"--cached",
|
||||
"--ignore-unmatch",
|
||||
"--quiet",
|
||||
"--",
|
||||
];
|
||||
args.extend(SEED_GITIGNORE.lines());
|
||||
git(dir, &args).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn commit_if_dirty(dir: &Path, message: Option<&str>) -> Result<bool, ExportGithubFailure> {
|
||||
let status = git(dir, &["status", "--porcelain"]).await?;
|
||||
if status.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
let message = message.unwrap_or("Export from Grok");
|
||||
git(dir, &["commit", "-m", message]).await?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn current_branch(dir: &Path) -> Result<String, ExportGithubFailure> {
|
||||
let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await?;
|
||||
if branch == "HEAD" {
|
||||
Ok(DEFAULT_BRANCH.to_owned())
|
||||
} else {
|
||||
Ok(branch)
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_remote(dir: &Path, url: &str) -> Result<(), ExportGithubFailure> {
|
||||
if git(dir, &["remote", "get-url", "origin"]).await.is_ok() {
|
||||
git(dir, &["remote", "set-url", "origin", url]).await?;
|
||||
} else {
|
||||
git(dir, &["remote", "add", "origin", url]).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn push(dir: &Path, branch: &str) -> Result<(), ExportGithubFailure> {
|
||||
let refspec = format!("HEAD:refs/heads/{branch}");
|
||||
match git(dir, &["push", "-u", "origin", &refspec]).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(failure) => Err(classify_push_failure(failure)),
|
||||
}
|
||||
}
|
||||
|
||||
const PUSH_REJECTED_MARKERS: [&str; 5] = [
|
||||
"non-fast-forward",
|
||||
"fetch first",
|
||||
"[rejected]",
|
||||
"updates were rejected",
|
||||
"failed to push some refs",
|
||||
];
|
||||
|
||||
const PUSH_AUTH_MARKERS: [&str; 8] = [
|
||||
"authentication failed",
|
||||
"could not read username",
|
||||
"permission to",
|
||||
"http basic: access denied",
|
||||
"invalid username or token",
|
||||
"authentication required",
|
||||
"returned error: 403",
|
||||
"returned error: 401",
|
||||
];
|
||||
|
||||
fn classify_push_failure(failure: ExportGithubFailure) -> ExportGithubFailure {
|
||||
let lower = failure.message.to_lowercase();
|
||||
let kind = if PUSH_AUTH_MARKERS.iter().any(|m| lower.contains(m)) {
|
||||
ExportGithubError::AuthFailed
|
||||
} else if PUSH_REJECTED_MARKERS.iter().any(|m| lower.contains(m)) {
|
||||
ExportGithubError::PushRejected
|
||||
} else {
|
||||
tracing::warn!(message = %failure.message, "git push failed with unclassified stderr");
|
||||
ExportGithubError::GitFailed
|
||||
};
|
||||
ExportGithubFailure::new(kind, failure.message)
|
||||
}
|
||||
|
||||
async fn git(dir: &Path, args: &[&str]) -> Result<String, ExportGithubFailure> {
|
||||
let mut cmd = xai_tty_utils::git_command();
|
||||
cmd.current_dir(dir)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_AUTHOR_NAME", AUTHOR_NAME)
|
||||
.env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
|
||||
.env("GIT_COMMITTER_NAME", AUTHOR_NAME)
|
||||
.env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL)
|
||||
.args(args);
|
||||
let mut async_cmd = tokio::process::Command::from(cmd);
|
||||
async_cmd.kill_on_drop(true);
|
||||
let output = async_cmd.output().await.map_err(|e| {
|
||||
ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
format!("spawning git {}: {e}", args.join(" ")),
|
||||
)
|
||||
})?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
} else {
|
||||
Err(ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
format!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mapping_file_path(project_dir: &Path) -> PathBuf {
|
||||
project_dir.join(GITHUB_REPO_MAPPING_FILE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("gh-export-test-{name}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn bare_remote(base: &Path, full_name: &str) -> String {
|
||||
let repo_path = base.join(format!("{full_name}.git"));
|
||||
std::fs::create_dir_all(&repo_path).unwrap();
|
||||
let mut cmd = xai_tty_utils::git_command();
|
||||
let out = cmd
|
||||
.args(["init", "--bare"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
format!("file://{}", base.display())
|
||||
}
|
||||
|
||||
fn params<'a>(
|
||||
project: &'a Path,
|
||||
repo: Option<&'a str>,
|
||||
base: &'a str,
|
||||
) -> ExportGithubParams<'a> {
|
||||
ExportGithubParams {
|
||||
project_dir: project,
|
||||
repo_full_name: repo,
|
||||
remote_url_base: base,
|
||||
web_url_base: "https://github.com",
|
||||
branch: None,
|
||||
commit_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_head(base: &Path, full_name: &str) -> String {
|
||||
let mut cmd = xai_tty_utils::git_command();
|
||||
let out = cmd
|
||||
.args(["rev-parse", "refs/heads/main"])
|
||||
.current_dir(base.join(format!("{full_name}.git")))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_export_inits_commits_and_pushes() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("index.html"), "<html></html>").unwrap();
|
||||
|
||||
let res = run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res.repo_full_name, "user/app");
|
||||
assert_eq!(res.repo_url, "https://github.com/user/app");
|
||||
assert_eq!(res.branch, "main");
|
||||
assert!(!res.no_changes);
|
||||
assert_eq!(remote_head(&remote_base, "user/app"), res.commit_sha);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join(GITHUB_REPO_MAPPING_FILE)).unwrap(),
|
||||
"user/app\n"
|
||||
);
|
||||
assert!(project.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_git_locks_are_removed_after_timeout() {
|
||||
let project = tmp_dir("project");
|
||||
let git_dir = project.join(".git");
|
||||
std::fs::create_dir_all(&git_dir).unwrap();
|
||||
for lock in ["index.lock", "HEAD.lock", "config.lock"] {
|
||||
std::fs::write(git_dir.join(lock), "").unwrap();
|
||||
}
|
||||
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
|
||||
remove_stale_git_locks(&project);
|
||||
|
||||
for lock in ["index.lock", "HEAD.lock", "config.lock"] {
|
||||
assert!(!git_dir.join(lock).exists(), "{lock} should be removed");
|
||||
}
|
||||
assert!(git_dir.join("HEAD").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_branch_and_commit_message_fall_back_to_defaults() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("index.html"), "<html></html>").unwrap();
|
||||
|
||||
let mut p = params(&project, Some("user/app"), &base);
|
||||
p.branch = Some("");
|
||||
p.commit_message = Some(" ");
|
||||
let res = run_export(p).await.unwrap();
|
||||
|
||||
assert_eq!(res.branch, "main");
|
||||
assert!(!res.no_changes);
|
||||
assert_eq!(remote_head(&remote_base, "user/app"), res.commit_sha);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preexisting_gitignore_still_excludes_sensitive_files() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join(".gitignore"), "dist/\n").unwrap();
|
||||
std::fs::write(project.join("index.html"), "<html></html>").unwrap();
|
||||
std::fs::write(project.join(".env"), "SECRET=1").unwrap();
|
||||
|
||||
run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tracked = git(&project, &["ls-tree", "-r", "--name-only", "HEAD"])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(tracked.contains("index.html"));
|
||||
assert!(!tracked.contains(".env"), "committed .env: {tracked}");
|
||||
assert!(
|
||||
!tracked.contains(GITHUB_REPO_MAPPING_FILE),
|
||||
"committed mapping file: {tracked}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join(".gitignore")).unwrap(),
|
||||
"dist/\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_export_reuses_mapping_and_reports_no_changes() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("main.py"), "print('hi')").unwrap();
|
||||
|
||||
let first = run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
let second = run_export(params(&project, None, &base)).await.unwrap();
|
||||
|
||||
assert_eq!(second.repo_full_name, "user/app");
|
||||
assert!(second.no_changes);
|
||||
assert_eq!(second.commit_sha, first.commit_sha);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_files_produce_a_new_commit_on_the_same_repo() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("a.txt"), "one").unwrap();
|
||||
|
||||
let first = run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(project.join("a.txt"), "two").unwrap();
|
||||
let second = run_export(params(&project, None, &base)).await.unwrap();
|
||||
|
||||
assert!(!second.no_changes);
|
||||
assert_ne!(second.commit_sha, first.commit_sha);
|
||||
assert_eq!(remote_head(&remote_base, "user/app"), second.commit_sha);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_repo_overrides_stale_mapping() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
bare_remote(&remote_base, "user/other");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("a.txt"), "one").unwrap();
|
||||
|
||||
run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
let res = run_export(params(&project, Some("user/other"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(res.repo_full_name, "user/other");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join(GITHUB_REPO_MAPPING_FILE)).unwrap(),
|
||||
"user/other\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_repo_and_mapping_is_a_typed_error() {
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("a.txt"), "one").unwrap();
|
||||
for repo in [None, Some(""), Some(" ")] {
|
||||
let err = run_export(params(&project, repo, "file:///nowhere"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
err.kind,
|
||||
ExportGithubError::RepoNotSpecified,
|
||||
"for {repo:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_repo_name_is_rejected_before_any_git_runs() {
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("a.txt"), "one").unwrap();
|
||||
for bad in ["justname", "a/b/c", "../evil/repo", "owner/na me"] {
|
||||
let err = run_export(params(&project, Some(bad), "file:///nowhere"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ExportGithubError::InvalidRepoName, "for {bad:?}");
|
||||
}
|
||||
assert!(!project.join(".git").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_gitignore_is_not_overwritten() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("a.txt"), "one").unwrap();
|
||||
std::fs::write(project.join(".gitignore"), "custom/\n").unwrap();
|
||||
|
||||
run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.join(".gitignore")).unwrap(),
|
||||
"custom/\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_fast_forward_push_maps_to_push_rejected() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
|
||||
let project_a = tmp_dir("project-a");
|
||||
std::fs::write(project_a.join("a.txt"), "one").unwrap();
|
||||
run_export(params(&project_a, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let project_b = tmp_dir("project-b");
|
||||
std::fs::write(project_b.join("b.txt"), "unrelated history").unwrap();
|
||||
let err = run_export(params(&project_b, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ExportGithubError::PushRejected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previously_tracked_secrets_are_untracked_on_export() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("index.html"), "<html></html>").unwrap();
|
||||
std::fs::write(project.join(".env"), "SECRET=1").unwrap();
|
||||
std::fs::write(project.join(".project_id"), "proj-123").unwrap();
|
||||
git(&project, &["init", "-b", "main"]).await.unwrap();
|
||||
git(&project, &["add", "-A"]).await.unwrap();
|
||||
git(&project, &["commit", "-m", "seed"]).await.unwrap();
|
||||
|
||||
let res = run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tracked = git(&project, &["ls-tree", "-r", "--name-only", "HEAD"])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(tracked.contains("index.html"));
|
||||
assert!(!tracked.contains(".env"), "still tracks .env: {tracked}");
|
||||
assert!(
|
||||
!tracked.contains(".project_id"),
|
||||
"still tracks .project_id: {tracked}"
|
||||
);
|
||||
assert!(!res.no_changes);
|
||||
assert!(project.join(".env").exists());
|
||||
assert!(project.join(".project_id").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requested_branch_is_used_on_reexport() {
|
||||
let remote_base = tmp_dir("remote");
|
||||
let base = bare_remote(&remote_base, "user/app");
|
||||
let project = tmp_dir("project");
|
||||
std::fs::write(project.join("a.txt"), "one").unwrap();
|
||||
|
||||
run_export(params(&project, Some("user/app"), &base))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(project.join("a.txt"), "two").unwrap();
|
||||
let mut second = params(&project, None, &base);
|
||||
second.branch = Some("feature");
|
||||
let res = run_export(second).await.unwrap();
|
||||
|
||||
assert_eq!(res.branch, "feature");
|
||||
let mut cmd = xai_tty_utils::git_command();
|
||||
let out = cmd
|
||||
.args(["rev-parse", "refs/heads/feature"])
|
||||
.current_dir(remote_base.join("user/app.git"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), res.commit_sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_failure_stderr_classification_table() {
|
||||
let cases = [
|
||||
(
|
||||
"! [rejected] main -> main (non-fast-forward)",
|
||||
ExportGithubError::PushRejected,
|
||||
),
|
||||
(
|
||||
"hint: Updates were rejected because the remote contains work that you do not have locally",
|
||||
ExportGithubError::PushRejected,
|
||||
),
|
||||
(
|
||||
"error: failed to push some refs to 'https://github.com/user/app.git'",
|
||||
ExportGithubError::PushRejected,
|
||||
),
|
||||
(
|
||||
"fatal: Authentication failed for 'https://github.com/user/app.git/'",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"fatal: could not read Username for 'https://github.com': terminal prompts disabled",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"remote: Permission to user/app.git denied to some-bot.",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"The requested URL returned error: 403",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"The requested URL returned error: 401",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"remote: HTTP Basic: Access denied",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"remote: Invalid username or token.",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"fatal: Authentication required",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"remote: Permission to user/app.git denied to some-bot.\nfatal: unable to access 'https://github.com/user/app.git/': The requested URL returned error: 403\nerror: failed to push some refs to 'https://github.com/user/app.git'",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"fatal: Authentication failed for 'https://github.com/user/app.git/'\nerror: failed to push some refs to 'https://github.com/user/app.git'",
|
||||
ExportGithubError::AuthFailed,
|
||||
),
|
||||
(
|
||||
"! [rejected] main -> main (fetch first)\nerror: failed to push some refs to 'https://github.com/user403/app401.git'\nhint: Updates were rejected because the remote contains work that you do not have locally",
|
||||
ExportGithubError::PushRejected,
|
||||
),
|
||||
(
|
||||
"fatal: unable to access 'https://github.com/user/app.git/': Could not resolve host: github.com",
|
||||
ExportGithubError::GitFailed,
|
||||
),
|
||||
];
|
||||
for (stderr, expected) in cases {
|
||||
let failure = classify_push_failure(ExportGithubFailure::new(
|
||||
ExportGithubError::GitFailed,
|
||||
stderr.to_owned(),
|
||||
));
|
||||
assert_eq!(failure.kind, expected, "for {stderr:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_project_dir_is_a_typed_error() {
|
||||
let ghost = std::env::temp_dir().join(format!("gh-export-ghost-{}", uuid::Uuid::new_v4()));
|
||||
let err = run_export(params(&ghost, Some("user/app"), "file:///nowhere"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind, ExportGithubError::ProjectDirInvalid);
|
||||
}
|
||||
}
|
||||
|
|
@ -7602,6 +7602,7 @@ pub(crate) mod tests {
|
|||
timeout_ms: 10_000,
|
||||
source_dir: std::path::PathBuf::from("/tmp"),
|
||||
extra_env: std::collections::HashMap::new(),
|
||||
layer: xai_grok_hooks::config::HookProvenance::File,
|
||||
};
|
||||
handle.shared.hook_registry.write().append_specs(vec![spec]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -526,11 +526,28 @@ impl WorkspaceRpcHandler {
|
|||
let cwd = self.workspace.root_cwd()?;
|
||||
let mut results = Vec::new();
|
||||
for ref_path in &refs {
|
||||
let full_path = if std::path::Path::new(ref_path).is_absolute() {
|
||||
let requested_path = if std::path::Path::new(ref_path).is_absolute() {
|
||||
std::path::PathBuf::from(ref_path)
|
||||
} else {
|
||||
cwd.join(ref_path)
|
||||
};
|
||||
let full_path = match self
|
||||
.workspace
|
||||
.confine_to_workspace_root(&requested_path)
|
||||
.await
|
||||
{
|
||||
Ok((confined, _)) => confined,
|
||||
Err(e) => {
|
||||
results.push(serde_json::json!({
|
||||
"path": requested_path.to_string_lossy(),
|
||||
"ref": ref_path,
|
||||
"exists": false,
|
||||
"content": Value::Null,
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let exists = full_path.exists();
|
||||
let content = if exists {
|
||||
tokio::fs::read_to_string(&full_path).await.ok()
|
||||
|
|
@ -601,6 +618,9 @@ impl WorkspaceRpcHandler {
|
|||
);
|
||||
Ok(Value::Array(plugins))
|
||||
}
|
||||
<ExportGithubReq as WorkspaceRpc>::METHOD => {
|
||||
dispatch_op::<ExportGithubReq>(params, &self.workspace, None).await
|
||||
}
|
||||
<HookRegistryReq as WorkspaceRpc>::METHOD => {
|
||||
dispatch_op::<HookRegistryReq>(params, &self.workspace, None).await
|
||||
}
|
||||
|
|
@ -1169,7 +1189,9 @@ impl ToolServerHandler for WorkspaceRpcHandler {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::capability::CapabilityMode;
|
||||
use crate::handle::tests::{background_capable_cfg, make_handle, start_background_sleep};
|
||||
use crate::handle::tests::{
|
||||
background_capable_cfg, make_confining_handle, make_handle, start_background_sleep,
|
||||
};
|
||||
use xai_grok_tools::implementations::grok_build::scheduler::types::{
|
||||
ScheduledTask, SchedulerState,
|
||||
};
|
||||
|
|
@ -2498,6 +2520,34 @@ mod tests {
|
|||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn dispatch_resolve_file_references_rejects_outside_root_when_confined() {
|
||||
let handle = make_confining_handle();
|
||||
let handler = WorkspaceRpcHandler::new(handle);
|
||||
let secret = std::env::temp_dir().join("h1_3885911_outside_secret.txt");
|
||||
std::fs::write(&secret, "OUTSIDE_SECRET").unwrap();
|
||||
let params = serde_json::json!({
|
||||
"refs": [secret.to_string_lossy(), "../escape.txt"]
|
||||
});
|
||||
let result = handler
|
||||
.dispatch("workspace.resolve_file_references", params, None)
|
||||
.await
|
||||
.expect("dispatch itself should succeed");
|
||||
let arr = result.as_array().expect("results array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
for entry in arr {
|
||||
assert_eq!(entry["exists"], serde_json::Value::Bool(false));
|
||||
assert_eq!(entry["content"], serde_json::Value::Null);
|
||||
assert!(
|
||||
entry["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("escapes workspace root"),
|
||||
"escape should be rejected, not read: {entry:?}"
|
||||
);
|
||||
}
|
||||
std::fs::remove_file(&secret).ok();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn handle_hook_pause_resume_are_noops() {
|
||||
let handle = make_handle();
|
||||
let handler = WorkspaceRpcHandler::new(handle);
|
||||
|
|
@ -3057,6 +3107,7 @@ mod tests {
|
|||
<InstallPluginReq as WorkspaceRpc>::METHOD,
|
||||
<RefreshPluginsReq as WorkspaceRpc>::METHOD,
|
||||
<DiscoverPluginsReq as WorkspaceRpc>::METHOD,
|
||||
<ExportGithubReq as WorkspaceRpc>::METHOD,
|
||||
];
|
||||
let skipped_global_db_mutators = [
|
||||
<WorktreeGcReq as WorkspaceRpc>::METHOD,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub mod diag_server;
|
|||
pub mod discovery;
|
||||
pub mod envrc;
|
||||
pub mod error;
|
||||
pub mod export_github;
|
||||
pub mod file_system;
|
||||
pub mod folder_trust;
|
||||
pub mod foreign_sessions;
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ use crate::permission::gate_preflight::GatePreflight;
|
|||
use crate::permission::policy::{CompiledPolicy, ShellWord};
|
||||
use crate::permission::prompter::{AcpPrompter, PromptOutcome};
|
||||
use crate::permission::shell_access::{
|
||||
command_write_paths_in_tree, edit_target_requires_prompt, is_safe_write_sink,
|
||||
tree_has_opaque_shell, words_are_opaque_shell,
|
||||
command_write_paths_in_tree, edit_target_protection, is_safe_write_sink, tree_has_opaque_shell,
|
||||
words_are_opaque_shell,
|
||||
};
|
||||
use crate::permission::state::{PermissionState, load_state_from_disk, persist_state};
|
||||
use crate::permission::types::{
|
||||
|
|
@ -1569,15 +1569,15 @@ fn spawn_permission_manager_with_pin(
|
|||
context.display_cwd.as_deref(),
|
||||
path,
|
||||
);
|
||||
edit_target_requires_prompt(&resolved)
|
||||
edit_target_protection(&resolved)
|
||||
}
|
||||
// Direct workspace callers predate per-request context and execute
|
||||
// against the manager cwd; the shell always supplies context.
|
||||
(AccessKind::Edit(path), None) => {
|
||||
let resolved = resolve_model_path(cwd.as_path(), None, path);
|
||||
edit_target_requires_prompt(&resolved)
|
||||
edit_target_protection(&resolved)
|
||||
}
|
||||
_ => false,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Evaluate managed policy (direct access + per-segment Bash command
|
||||
|
|
@ -1627,7 +1627,7 @@ fn spawn_permission_manager_with_pin(
|
|||
// Ask floors fall through so managed Ask / shell-file Ask stay binding.
|
||||
if !policy_forced_prompt
|
||||
&& !shell_forced_prompt
|
||||
&& !protected_edit
|
||||
&& protected_edit.is_none()
|
||||
&& let Some((decision, reason)) = session_grant_pre_decision(
|
||||
&access,
|
||||
bash_evaluation.as_ref(),
|
||||
|
|
@ -1651,7 +1651,7 @@ fn spawn_permission_manager_with_pin(
|
|||
if auto_mode
|
||||
&& !policy_forced_prompt
|
||||
&& !shell_forced_prompt
|
||||
&& !protected_edit
|
||||
&& protected_edit.is_none()
|
||||
&& !bash_request_floor_requires_prompt(bash_evaluation.as_ref())
|
||||
&& matches!(policy_decision, Some(Decision::Allow))
|
||||
{
|
||||
|
|
@ -1681,8 +1681,8 @@ fn spawn_permission_manager_with_pin(
|
|||
AutoFastPath, ClassifierVerdict, access_requires_user_interaction,
|
||||
auto_mode_fast_path,
|
||||
};
|
||||
let needs_user =
|
||||
protected_edit || access_requires_user_interaction(&tool_name, &access);
|
||||
let needs_user = protected_edit.is_some()
|
||||
|| access_requires_user_interaction(&tool_name, &access);
|
||||
let fast = auto_mode_fast_path(&access, &tool_name, needs_user);
|
||||
match fast {
|
||||
AutoFastPath::Allow => {
|
||||
|
|
@ -1901,7 +1901,7 @@ fn spawn_permission_manager_with_pin(
|
|||
);
|
||||
}
|
||||
Some(Decision::Allow)
|
||||
if protected_edit
|
||||
if protected_edit.is_some()
|
||||
|| bash_request_floor_requires_prompt(bash_evaluation.as_ref()) =>
|
||||
{
|
||||
tracing::info!(
|
||||
|
|
@ -1961,7 +1961,7 @@ fn spawn_permission_manager_with_pin(
|
|||
)
|
||||
.map(|d| (d, reasons::PERSISTED_GRANT)),
|
||||
AccessKind::Edit(_) => {
|
||||
if allow_edits_for_session && !protected_edit {
|
||||
if allow_edits_for_session && protected_edit.is_none() {
|
||||
Some((Decision::Allow, reasons::PERSISTED_GRANT))
|
||||
} else {
|
||||
match state.edit_policy {
|
||||
|
|
@ -2117,7 +2117,7 @@ fn spawn_permission_manager_with_pin(
|
|||
// (e.g. `curl … && sh` must not become two separate
|
||||
// prompts for `curl …` then `sh`).
|
||||
let prompt_outcome = tokio::select! {
|
||||
outcome = prompter.request(&access, &tool_call_update) => outcome,
|
||||
outcome = prompter.request(&access, &tool_call_update, protected_edit) => outcome,
|
||||
_ = respond_to.closed() => PromptOutcome::Cancelled,
|
||||
};
|
||||
|
||||
|
|
@ -2173,7 +2173,7 @@ fn spawn_permission_manager_with_pin(
|
|||
_ => {
|
||||
// Non-bash access kinds keep the single-prompt flow.
|
||||
let prompt_outcome = tokio::select! {
|
||||
outcome = prompter.request(&access, &tool_call_update) => outcome,
|
||||
outcome = prompter.request(&access, &tool_call_update, protected_edit) => outcome,
|
||||
_ = respond_to.closed() => PromptOutcome::Cancelled,
|
||||
};
|
||||
let (decision, outcome_str) = match &prompt_outcome {
|
||||
|
|
@ -5123,46 +5123,43 @@ mod tests {
|
|||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let mut auto = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
auto.prompt_policy = PromptPolicy::Auto;
|
||||
let allow = crate::permission::types::PermissionConfig::new(vec![PermissionRule {
|
||||
action: RuleAction::Allow,
|
||||
tool: ToolFilter::Edit,
|
||||
pattern: None,
|
||||
pattern_mode: Default::default(),
|
||||
}]);
|
||||
let mut deny = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
deny.prompt_policy = PromptPolicy::Deny;
|
||||
for path in ["/etc/hosts", "/home/user/.grok/hooks/evil.json"] {
|
||||
let mut auto = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
auto.prompt_policy = PromptPolicy::Auto;
|
||||
let allow =
|
||||
crate::permission::types::PermissionConfig::new(vec![PermissionRule {
|
||||
action: RuleAction::Allow,
|
||||
tool: ToolFilter::Edit,
|
||||
pattern: None,
|
||||
pattern_mode: Default::default(),
|
||||
}]);
|
||||
let mut deny = crate::permission::types::PermissionConfig::new(vec![]);
|
||||
deny.prompt_policy = PromptPolicy::Deny;
|
||||
|
||||
for (name, config, expected_prompts, policy_deny) in [
|
||||
("auto", auto, 1, false),
|
||||
("configured allow", allow, 1, false),
|
||||
("dontAsk", deny, 0, true),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let client = RecordingClient::default();
|
||||
let prompts = client.prompts.clone();
|
||||
let (mgr, _events) = manager_with_recording_client(
|
||||
&cwd,
|
||||
Some(config),
|
||||
client,
|
||||
ClientType::Generic,
|
||||
);
|
||||
let decision = mgr
|
||||
.request(
|
||||
AccessKind::Edit("/etc/hosts".into()),
|
||||
tool_call(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(prompts.borrow().len(), expected_prompts, "{name}");
|
||||
if policy_deny {
|
||||
assert!(matches!(decision, Decision::PolicyDeny(_)), "{name}");
|
||||
} else {
|
||||
assert!(matches!(decision, Decision::Reject(_)), "{name}");
|
||||
for (name, config, expected_prompts, policy_deny) in [
|
||||
("auto", auto, 1, false),
|
||||
("configured allow", allow, 1, false),
|
||||
("dontAsk", deny, 0, true),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cwd = AbsPathBuf::new(tmp.path().to_path_buf()).unwrap();
|
||||
let client = RecordingClient::default();
|
||||
let prompts = client.prompts.clone();
|
||||
let (mgr, _events) = manager_with_recording_client(
|
||||
&cwd,
|
||||
Some(config),
|
||||
client,
|
||||
ClientType::Generic,
|
||||
);
|
||||
let decision = mgr
|
||||
.request(AccessKind::Edit(path.into()), tool_call(), None, None, None)
|
||||
.await;
|
||||
assert_eq!(prompts.borrow().len(), expected_prompts, "{name} {path}");
|
||||
if policy_deny {
|
||||
assert!(matches!(decision, Decision::PolicyDeny(_)), "{name} {path}");
|
||||
} else {
|
||||
assert!(matches!(decision, Decision::Reject(_)), "{name} {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub use prompter::{
|
|||
PromptOutcome, is_enable_always_approve_option, mcp_pretty_name_if_qualified,
|
||||
mcp_titleize_segment, mcp_tool_action, mcp_tool_display_name,
|
||||
};
|
||||
pub use shell_access::{ProtectedEditPermission, ProtectedEditReason};
|
||||
pub use state::PermissionState;
|
||||
pub use state::cleanup_stale_permission_state;
|
||||
pub use types::{AccessKind, ClientType, Decision, PermissionCommand, PermissionEvent};
|
||||
|
|
|
|||
|
|
@ -565,6 +565,22 @@ impl AcpPrompter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Request `_meta`: bash selection scope, or protected-edit description for Edit.
|
||||
fn permission_request_meta(
|
||||
&self,
|
||||
access: &AccessKind,
|
||||
protected_edit: Option<crate::permission::ProtectedEditReason>,
|
||||
) -> Option<acp::Meta> {
|
||||
if let Some(bash) = self.bash_selection_meta(access) {
|
||||
return Some(bash);
|
||||
}
|
||||
let reason = protected_edit?;
|
||||
let payload = crate::permission::ProtectedEditPermission::from_reason(reason);
|
||||
serde_json::to_value(payload)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
}
|
||||
|
||||
/// Build the per-access-kind option map WITHOUT the
|
||||
/// "enable always-approve mode" prepend. Kept as a separate inner
|
||||
/// fn so `build_options` can wrap the result with one prepend call
|
||||
|
|
@ -719,6 +735,7 @@ impl AcpPrompter {
|
|||
&self,
|
||||
access: &AccessKind,
|
||||
tool_call_update: &acp::ToolCallUpdate,
|
||||
protected_edit: Option<crate::permission::ProtectedEditReason>,
|
||||
) -> PromptOutcome {
|
||||
let tool_name = tool_name_for_access(access);
|
||||
// events.jsonl: `PermissionRequested` at prompt-start. The `Instant`
|
||||
|
|
@ -753,7 +770,7 @@ impl AcpPrompter {
|
|||
tool_call_update.clone(),
|
||||
permission_options.values().cloned().collect(),
|
||||
)
|
||||
.meta(self.bash_selection_meta(access));
|
||||
.meta(self.permission_request_meta(access, protected_edit));
|
||||
match self.gateway.request_permission(req).await {
|
||||
Ok(resp) => match resp.outcome {
|
||||
acp::RequestPermissionOutcome::Cancelled => PromptOutcome::Cancelled,
|
||||
|
|
@ -1625,7 +1642,7 @@ mod tests {
|
|||
acp::ToolCallUpdateFields::default(),
|
||||
);
|
||||
|
||||
let outcome = prompter.request(&access, &tool_call_update).await;
|
||||
let outcome = prompter.request(&access, &tool_call_update, None).await;
|
||||
assert!(
|
||||
matches!(outcome, PromptOutcome::Error(_)),
|
||||
"dropped gateway receiver should yield PromptOutcome::Error"
|
||||
|
|
@ -1675,7 +1692,7 @@ mod tests {
|
|||
acp::ToolCallId::new(Arc::from("tc-2")),
|
||||
acp::ToolCallUpdateFields::default(),
|
||||
);
|
||||
let outcome = prompter.request(&access, &tool_call_update).await;
|
||||
let outcome = prompter.request(&access, &tool_call_update, None).await;
|
||||
assert!(matches!(outcome, PromptOutcome::Error(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,26 +319,109 @@ pub(crate) fn is_safe_write_sink(path: &str) -> bool {
|
|||
matches!(path, "/dev/null" | "/dev/stdout" | "/dev/stderr")
|
||||
}
|
||||
|
||||
/// Whether an already-resolved direct edit target needs explicit confirmation.
|
||||
/// Why acceptEdits must still prompt for this edit target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProtectedEditReason {
|
||||
HookRoot,
|
||||
GitHooks,
|
||||
Ssh,
|
||||
StartupFile,
|
||||
Etc,
|
||||
GrokConfig,
|
||||
ClaudeSettings,
|
||||
CursorHooks,
|
||||
/// Fail-closed / unclassified sensitive path; no user copy yet.
|
||||
Sensitive,
|
||||
}
|
||||
|
||||
impl ProtectedEditReason {
|
||||
pub fn kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::HookRoot => "hook_root",
|
||||
Self::GitHooks => "git_hooks",
|
||||
Self::Ssh => "ssh",
|
||||
Self::StartupFile => "startup_file",
|
||||
Self::Etc => "etc",
|
||||
Self::GrokConfig => "grok_config",
|
||||
Self::ClaudeSettings => "claude_settings",
|
||||
Self::CursorHooks => "cursor_hooks",
|
||||
Self::Sensitive => "sensitive",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::HookRoot => Some(
|
||||
"Note: This edit contains changes to hooks, which can be executed as code on later sessions without a separate execution approval.",
|
||||
),
|
||||
Self::GitHooks => Some(
|
||||
"Note: This edit contains changes to Git hooks, which can run automatically on commit, push, or other Git actions without a separate execution approval.",
|
||||
),
|
||||
Self::Ssh => Some(
|
||||
"Note: This edit contains changes under `.ssh`, which can affect credentials and authentication for future sessions.",
|
||||
),
|
||||
Self::StartupFile => Some(
|
||||
"Note: This edit contains changes to a shell startup file, which can run automatically in future terminals without a separate execution approval.",
|
||||
),
|
||||
Self::Etc => Some(
|
||||
"Note: This edit contains changes under `/etc`, which is system configuration and can affect this machine beyond the current project.",
|
||||
),
|
||||
Self::GrokConfig => Some(
|
||||
"Note: This edit contains changes to Grok config, which can alter permissions, tools, and other behavior in later sessions.",
|
||||
),
|
||||
Self::ClaudeSettings => Some(
|
||||
"Note: This edit contains changes to Claude-compatible settings, which can install hooks or change permission mode without a separate execution approval.",
|
||||
),
|
||||
Self::CursorHooks => Some(
|
||||
"Note: This edit contains changes to Cursor hooks, which can run automatically in later sessions without a separate execution approval.",
|
||||
),
|
||||
Self::Sensitive => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ACP `_meta` payload for protected-edit prompts (pager reads this for description).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProtectedEditPermission {
|
||||
pub kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl ProtectedEditPermission {
|
||||
pub fn from_reason(reason: ProtectedEditReason) -> Self {
|
||||
Self {
|
||||
kind: reason.kind().to_owned(),
|
||||
description: reason.description().map(str::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an already-resolved direct edit target needs confirmation, and why.
|
||||
///
|
||||
/// The caller uses the edit tools' shared model-path resolver first. This helper
|
||||
/// preserves its uncollapsed components for physical symlink + `..` resolution,
|
||||
/// while checking a separate lexical normalization for traversal aliases.
|
||||
pub(crate) fn edit_target_requires_prompt(path: &Path) -> bool {
|
||||
pub(crate) fn edit_target_protection(path: &Path) -> Option<ProtectedEditReason> {
|
||||
if !path.is_absolute() {
|
||||
return true;
|
||||
return Some(ProtectedEditReason::Sensitive);
|
||||
}
|
||||
let lexical = xai_grok_paths::normalize_lexically(path);
|
||||
if protected_edit_path(&lexical) {
|
||||
return true;
|
||||
if let Some(reason) = protected_edit_reason(&lexical) {
|
||||
return Some(reason);
|
||||
}
|
||||
let Some(resolved) = resolve_following_symlinks(path, 0) else {
|
||||
return true;
|
||||
return Some(ProtectedEditReason::Sensitive);
|
||||
};
|
||||
protected_edit_path(&resolved) || resolved_path_is_within_root(&resolved, Path::new("/etc"))
|
||||
if let Some(reason) = protected_edit_reason(&resolved) {
|
||||
return Some(reason);
|
||||
}
|
||||
resolved_path_is_within_root(&resolved, Path::new("/etc"))
|
||||
.then_some(ProtectedEditReason::Sensitive)
|
||||
}
|
||||
|
||||
fn protected_edit_path(path: &Path) -> bool {
|
||||
fn protected_edit_reason(path: &Path) -> Option<ProtectedEditReason> {
|
||||
let components: Vec<String> = path
|
||||
.components()
|
||||
.filter_map(|component| match component {
|
||||
|
|
@ -368,12 +451,49 @@ fn protected_edit_path(path: &Path) -> bool {
|
|||
".xprofile",
|
||||
];
|
||||
|
||||
STARTUP_FILES.contains(&file)
|
||||
|| protected_git_hooks_path(&string_components)
|
||||
|| string_components.contains(&".ssh")
|
||||
|| string_components.ends_with(&[".grok", "config.toml"])
|
||||
|| path == Path::new("/etc")
|
||||
|| path.starts_with(Path::new("/etc"))
|
||||
if protected_grok_hook_root(path, &string_components) {
|
||||
return Some(ProtectedEditReason::HookRoot);
|
||||
}
|
||||
if string_components.ends_with(&[".claude", "settings.json"])
|
||||
|| string_components.ends_with(&[".claude", "settings.local.json"])
|
||||
{
|
||||
return Some(ProtectedEditReason::ClaudeSettings);
|
||||
}
|
||||
if string_components.ends_with(&[".cursor", "hooks.json"]) {
|
||||
return Some(ProtectedEditReason::CursorHooks);
|
||||
}
|
||||
if protected_git_hooks_path(&string_components) {
|
||||
return Some(ProtectedEditReason::GitHooks);
|
||||
}
|
||||
if string_components.contains(&".ssh") {
|
||||
return Some(ProtectedEditReason::Ssh);
|
||||
}
|
||||
if STARTUP_FILES.contains(&file) {
|
||||
return Some(ProtectedEditReason::StartupFile);
|
||||
}
|
||||
if string_components.ends_with(&[".grok", "config.toml"]) {
|
||||
return Some(ProtectedEditReason::GrokConfig);
|
||||
}
|
||||
if path == Path::new("/etc") || path.starts_with(Path::new("/etc")) {
|
||||
return Some(ProtectedEditReason::Etc);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn path_is_under_user_grok_hook_root(path: &Path, grok_home: &Path) -> bool {
|
||||
path.starts_with(grok_home.join("hooks")) || path == grok_home.join("hooks-paths")
|
||||
}
|
||||
|
||||
fn protected_grok_hook_root(path: &Path, components: &[&str]) -> bool {
|
||||
components.windows(2).any(|pair| pair == [".grok", "hooks"])
|
||||
|| components.ends_with(&[".grok", "hooks-paths"])
|
||||
|| xai_grok_config::user_grok_home().is_some_and(|grok_home| {
|
||||
let lexical_home = xai_grok_paths::normalize_lexically(&grok_home);
|
||||
path_is_under_user_grok_hook_root(path, &lexical_home)
|
||||
|| resolve_following_symlinks(&lexical_home, 0).is_some_and(|resolved_home| {
|
||||
path_is_under_user_grok_hook_root(path, &resolved_home)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn protected_git_hooks_path(components: &[&str]) -> bool {
|
||||
|
|
@ -1251,7 +1371,7 @@ mod tests {
|
|||
"/work/subdir/../.git/hooks/pre-commit",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_requires_prompt(Path::new(path)),
|
||||
edit_target_protection(Path::new(path)).is_some(),
|
||||
"protected edit target must prompt: {path}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1260,7 +1380,7 @@ mod tests {
|
|||
"/work/project/.grok/config.toml/backup",
|
||||
] {
|
||||
assert!(
|
||||
!edit_target_requires_prompt(Path::new(path)),
|
||||
edit_target_protection(Path::new(path)).is_none(),
|
||||
"ordinary edit target should not prompt: {path}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1275,7 +1395,7 @@ mod tests {
|
|||
"/work/subdir/../.git/modules/foo/hooks/pre-commit",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_requires_prompt(Path::new(path)),
|
||||
edit_target_protection(Path::new(path)).is_some(),
|
||||
"submodule hook target must prompt: {path}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1287,12 +1407,110 @@ mod tests {
|
|||
"/work/src/modules/foo/hooks/pre-commit",
|
||||
] {
|
||||
assert!(
|
||||
!edit_target_requires_prompt(Path::new(path)),
|
||||
edit_target_protection(Path::new(path)).is_none(),
|
||||
"non-hook control must not prompt: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_target_protection_classifies_reasons() {
|
||||
let cases = [
|
||||
(
|
||||
"/home/user/.grok/hooks/evil.json",
|
||||
ProtectedEditReason::HookRoot,
|
||||
),
|
||||
("/work/.git/hooks/pre-commit", ProtectedEditReason::GitHooks),
|
||||
("/home/user/.ssh/id_rsa", ProtectedEditReason::Ssh),
|
||||
("/home/user/.zshrc", ProtectedEditReason::StartupFile),
|
||||
("/etc/hosts", ProtectedEditReason::Etc),
|
||||
(
|
||||
"/home/user/.grok/config.toml",
|
||||
ProtectedEditReason::GrokConfig,
|
||||
),
|
||||
(
|
||||
"/home/user/.claude/settings.json",
|
||||
ProtectedEditReason::ClaudeSettings,
|
||||
),
|
||||
(
|
||||
"/home/user/.cursor/hooks.json",
|
||||
ProtectedEditReason::CursorHooks,
|
||||
),
|
||||
];
|
||||
for (path, reason) in cases {
|
||||
assert_eq!(
|
||||
edit_target_protection(Path::new(path)),
|
||||
Some(reason),
|
||||
"{path}"
|
||||
);
|
||||
assert!(reason.description().is_some(), "{path}");
|
||||
}
|
||||
assert_eq!(
|
||||
edit_target_protection(Path::new("/home/user/project/src/main.rs")),
|
||||
None
|
||||
);
|
||||
assert!(ProtectedEditReason::Sensitive.description().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_edit_targets_include_hook_roots() {
|
||||
for path in [
|
||||
"/home/user/.grok/hooks/evil.json",
|
||||
"/home/user/.grok/hooks/nested/deep.json",
|
||||
"/home/user/.grok/hooks-paths",
|
||||
"/home/user/.claude/settings.json",
|
||||
"/home/user/.claude/settings.local.json",
|
||||
"/home/user/.cursor/hooks.json",
|
||||
"/work/project/.grok/hooks/local.json",
|
||||
"/work/project/.grok/hooks-paths",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_protection(Path::new(path)).is_some(),
|
||||
"hook root edit target must prompt: {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/home/user/.grok/hooks-disabled/note.json",
|
||||
"/home/user/.grok/hooks-evil/note.json",
|
||||
"/home/user/project/src/hooks.json",
|
||||
"/home/user/.claude/other.json",
|
||||
"/home/user/.cursor/settings.json",
|
||||
] {
|
||||
assert!(
|
||||
edit_target_protection(Path::new(path)).is_none(),
|
||||
"ordinary edit target should not prompt: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_is_under_user_grok_hook_root_matches_relocated_home() {
|
||||
let home = Path::new("/custom/grok-home");
|
||||
for path in [
|
||||
"/custom/grok-home/hooks/x.json",
|
||||
"/custom/grok-home/hooks/nested/deep.json",
|
||||
"/custom/grok-home/hooks",
|
||||
"/custom/grok-home/hooks-paths",
|
||||
] {
|
||||
assert!(
|
||||
path_is_under_user_grok_hook_root(Path::new(path), home),
|
||||
"must match under custom grok home: {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/custom/grok-home/hooks-disabled/note.json",
|
||||
"/custom/grok-home/hooks-evil/note.json",
|
||||
"/custom/grok-home/config.toml",
|
||||
"/custom/other/hooks/x.json",
|
||||
"/custom/grok-home-extra/hooks/x.json",
|
||||
] {
|
||||
assert!(
|
||||
!path_is_under_user_grok_hook_root(Path::new(path), home),
|
||||
"must not match outside hook roots: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn sensitive_edit_targets_follow_symlinks() {
|
||||
|
|
@ -1314,14 +1532,19 @@ mod tests {
|
|||
ws.path().join("module-hooks-link"),
|
||||
)
|
||||
.unwrap();
|
||||
let grok_hook = outside.path().join(".grok/hooks/evil.json");
|
||||
std::fs::create_dir_all(grok_hook.parent().unwrap()).unwrap();
|
||||
std::fs::write(&grok_hook, b"{}").unwrap();
|
||||
symlink(&grok_hook, ws.path().join("grok-hook-link")).unwrap();
|
||||
|
||||
for path in [
|
||||
ws.path().join("file-link"),
|
||||
ws.path().join("hooks-link/new-hook"),
|
||||
ws.path().join("module-hooks-link/new-hook"),
|
||||
ws.path().join("grok-hook-link"),
|
||||
] {
|
||||
assert!(
|
||||
edit_target_requires_prompt(&path),
|
||||
edit_target_protection(&path).is_some(),
|
||||
"symlinked protected edit target must prompt: {}",
|
||||
path.display()
|
||||
);
|
||||
|
|
@ -1344,7 +1567,7 @@ mod tests {
|
|||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn private_etc_alias_requires_prompt() {
|
||||
assert!(edit_target_requires_prompt(Path::new("/private/etc/hosts")));
|
||||
assert!(edit_target_protection(Path::new("/private/etc/hosts")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,16 +3,12 @@
|
|||
//!
|
||||
//! `error_code` uses a non-wildcard match so the compiler enforces
|
||||
//! coverage of new `WorkspaceError` variants.
|
||||
|
||||
pub use xai_grok_workspace_types::rpc::{RpcEnvelope, RpcError};
|
||||
|
||||
use crate::error::WorkspaceError;
|
||||
|
||||
pub use xai_grok_workspace_types::rpc::{RpcEnvelope, RpcError};
|
||||
/// Build an error envelope from a `WorkspaceError`.
|
||||
pub fn envelope_err<T>(error: &WorkspaceError) -> RpcEnvelope<T> {
|
||||
RpcEnvelope::err_parts(error_code(error), error.to_string())
|
||||
}
|
||||
|
||||
/// Map a `WorkspaceError` to its wire code string.
|
||||
///
|
||||
/// Uses an exhaustive match with no wildcard -- the compiler will
|
||||
|
|
@ -34,12 +30,11 @@ pub fn error_code(err: &WorkspaceError) -> &'static str {
|
|||
WorkspaceError::InvalidHunkAction(_) => "invalid_hunk_action",
|
||||
WorkspaceError::HunkActionFailed(_) => "hunk_action_failed",
|
||||
WorkspaceError::HubError(_) => "hub_error",
|
||||
WorkspaceError::DeployError { kind, .. } => kind.wire_code(),
|
||||
WorkspaceError::ExportGithub { kind, .. } => kind.wire_code(),
|
||||
WorkspaceError::ShuttingDown => "shutting_down",
|
||||
WorkspaceError::ToolsetExternallyOwned(_) => "toolset_externally_owned",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a wire [`RpcError`] back to a [`WorkspaceError`].
|
||||
///
|
||||
/// Known codes are mapped to their specific variants. Unknown codes
|
||||
|
|
@ -59,9 +54,9 @@ pub fn error_code(err: &WorkspaceError) -> &'static str {
|
|||
/// prefix (e.g. `"capability_widening: ..."`).
|
||||
pub fn rpc_error_to_workspace(err: RpcError) -> WorkspaceError {
|
||||
if let Some(kind) =
|
||||
xai_grok_workspace_types::rpc::deploy::DeployError::from_wire_code(&err.code)
|
||||
xai_grok_workspace_types::rpc::export_github::ExportGithubError::from_wire_code(&err.code)
|
||||
{
|
||||
return WorkspaceError::DeployError {
|
||||
return WorkspaceError::ExportGithub {
|
||||
kind,
|
||||
message: err.message,
|
||||
};
|
||||
|
|
@ -92,12 +87,10 @@ pub fn rpc_error_to_workspace(err: RpcError) -> WorkspaceError {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capability::CapabilityMode;
|
||||
|
||||
/// Verify round-trip fidelity for every `WorkspaceError` variant.
|
||||
#[test]
|
||||
fn error_code_round_trip_all_variants() {
|
||||
|
|
@ -125,32 +118,15 @@ mod tests {
|
|||
WorkspaceError::ShuttingDown,
|
||||
WorkspaceError::ToolsetExternallyOwned("s".into()),
|
||||
];
|
||||
variants.extend(
|
||||
xai_grok_workspace_types::rpc::deploy::DeployError::ALL
|
||||
.into_iter()
|
||||
.map(|kind| WorkspaceError::DeployError {
|
||||
kind,
|
||||
message: "deploy".into(),
|
||||
}),
|
||||
);
|
||||
|
||||
for err in &variants {
|
||||
let code = error_code(err);
|
||||
assert!(!code.is_empty(), "code must not be empty for {err:?}");
|
||||
|
||||
// Round-trip through RpcError
|
||||
let rpc_err = RpcError {
|
||||
code: code.to_owned(),
|
||||
message: err.to_string(),
|
||||
};
|
||||
let recovered = rpc_error_to_workspace(rpc_err);
|
||||
// The recovered error's code should match the original code
|
||||
let recovered_code = error_code(&recovered);
|
||||
|
||||
// Structured variants (CapabilityWidening, Unauthorized,
|
||||
// MaxDepthExceeded) lose their fields on the wire and
|
||||
// degrade to HubError, which is the expected behavior.
|
||||
// Their error messages are preserved in the HubError string.
|
||||
match err {
|
||||
WorkspaceError::CapabilityWidening { .. } => {
|
||||
assert_eq!(recovered_code, "hub_error");
|
||||
|
|
@ -185,9 +161,26 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify unknown codes degrade to HubError.
|
||||
#[test]
|
||||
fn export_github_codes_round_trip_typed() {
|
||||
for kind in xai_grok_workspace_types::rpc::export_github::ExportGithubError::ALL {
|
||||
let err = WorkspaceError::ExportGithub {
|
||||
kind,
|
||||
message: "boom".into(),
|
||||
};
|
||||
let rpc_err = RpcError {
|
||||
code: error_code(&err).into(),
|
||||
message: "boom".into(),
|
||||
};
|
||||
let recovered = rpc_error_to_workspace(rpc_err);
|
||||
assert!(
|
||||
matches!(recovered, WorkspaceError::ExportGithub { kind: k, .. } if k == kind),
|
||||
"lost typed export error for {kind:?}: {recovered:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unknown_code_degrades_to_hub_error() {
|
||||
let rpc_err = RpcError {
|
||||
code: "future_new_variant".into(),
|
||||
|
|
@ -198,7 +191,6 @@ mod tests {
|
|||
let msg = recovered.to_string();
|
||||
assert!(msg.contains("future_new_variant"));
|
||||
}
|
||||
|
||||
/// Verify serde round-trip of RpcEnvelope.
|
||||
#[test]
|
||||
fn envelope_serde_round_trip_ok() {
|
||||
|
|
@ -210,7 +202,6 @@ mod tests {
|
|||
Err(e) => panic!("expected Ok, got {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify serde round-trip of RpcEnvelope error, through the
|
||||
/// `WorkspaceError` mapping in both directions.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub use xai_grok_workspace_types::rpc::code_nav::{
|
|||
CodeFindDefinitionsReq, CodeFindReferencesReq, CodeGotoDefinitionReq, CodeGotoReferencesReq,
|
||||
CodeIndexStats, CodeIndexStatusReq, CodeIndexStatusResponse, CodeNavLocation, CodeNavResponse,
|
||||
};
|
||||
pub use xai_grok_workspace_types::rpc::export_github::ExportGithubReq;
|
||||
pub use xai_grok_workspace_types::rpc::fs::{
|
||||
ClientFsListNode, ClientFsListReq, ClientFsListRes, ClientFsReadFileReq, ClientFsReadFileRes,
|
||||
ClientFsStatReq, ClientFsStatRes, GetFileEntry, GetFileResult, GetFilesReq, GetFilesRes,
|
||||
|
|
@ -98,6 +99,37 @@ pub trait WorkspaceOp: WorkspaceRpc + DeserializeOwned + Send + Sync {
|
|||
pub struct PrepareWorktreeFromWorktreeReq {
|
||||
pub inner: crate::worktree::CreateWorktreeFromWorktreeRequest,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WorkspaceOp for ExportGithubReq {
|
||||
async fn execute(
|
||||
&self,
|
||||
ws: &WorkspaceHandle,
|
||||
_session_id: Option<&str>,
|
||||
) -> WorkspaceResult<Self::Response> {
|
||||
if std::path::Path::new(&self.project_dir).is_absolute() {
|
||||
return Err(WorkspaceError::HubError(
|
||||
"project_dir must be relative to the workspace root".into(),
|
||||
));
|
||||
}
|
||||
let canonical_root = ws.canonical_root().await?;
|
||||
let project_dir = ws
|
||||
.resolve_service_path(&self.project_dir, &canonical_root)
|
||||
.await?;
|
||||
crate::export_github::run_export(crate::export_github::ExportGithubParams {
|
||||
project_dir: &project_dir,
|
||||
repo_full_name: self.repo_full_name.as_deref(),
|
||||
remote_url_base: "https://github.com",
|
||||
web_url_base: "https://github.com",
|
||||
branch: self.branch.as_deref(),
|
||||
commit_message: self.commit_message.as_deref(),
|
||||
})
|
||||
.await
|
||||
.map_err(|failure| WorkspaceError::ExportGithub {
|
||||
kind: failure.kind,
|
||||
message: failure.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
/// Get all rewind points for the session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GetRewindPointsReq {
|
||||
|
|
@ -856,10 +888,8 @@ impl WorkspaceOp for ContentSearchRequest {
|
|||
ws.run_content_search(cwd, context_id, params).await
|
||||
}
|
||||
}
|
||||
/// Convert the heavy `HookRegistry` to its wire mirror via a serde round-trip.
|
||||
/// The registry's `hooks` map is private, so reconstructing field-by-field
|
||||
/// isn't possible; the round-trip is faithful because the wire type mirrors the
|
||||
/// serde shape exactly (the compiled `matcher` is `#[serde(skip)]` either way).
|
||||
/// Convert `HookRegistry` to its wire mirror. The `hooks` map is private, so a
|
||||
/// serde round-trip stands in for field-by-field construction.
|
||||
fn hook_registry_to_wire(
|
||||
registry: &xai_grok_hooks::discovery::HookRegistry,
|
||||
) -> WorkspaceResult<HookRegistryWire> {
|
||||
|
|
@ -867,13 +897,37 @@ fn hook_registry_to_wire(
|
|||
serde_json::to_value(registry).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))
|
||||
}
|
||||
/// Inverse of [`hook_registry_to_wire`]. Rebuilds compiled matchers via
|
||||
/// [`HookRegistry::recompile_matchers`] so invalid patterns fail closed
|
||||
/// (match nothing) rather than widening to match-all after the wire hop.
|
||||
/// Inverse of [`hook_registry_to_wire`]. Unknown event keys (a newer peer) are
|
||||
/// dropped so one can't fail the whole decode, and matchers are recompiled
|
||||
/// fail-closed after the hop.
|
||||
fn wire_to_hook_registry(
|
||||
wire: &HookRegistryWire,
|
||||
) -> WorkspaceResult<xai_grok_hooks::discovery::HookRegistry> {
|
||||
let value = serde_json::to_value(wire).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
let dropped: Vec<&str> = wire
|
||||
.hooks
|
||||
.keys()
|
||||
.filter_map(|event| match event {
|
||||
HookEventNameWire::Unknown(name) => Some(name.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !dropped.is_empty() {
|
||||
tracing::debug!(
|
||||
dropped_count = dropped.len(),
|
||||
dropped_events = ?dropped,
|
||||
"dropping unknown hook event keys from peer wire registry"
|
||||
);
|
||||
}
|
||||
let known = HookRegistryWire {
|
||||
hooks: wire
|
||||
.hooks
|
||||
.iter()
|
||||
.filter(|(event, _)| !matches!(event, HookEventNameWire::Unknown(_)))
|
||||
.map(|(event, specs)| (event.clone(), specs.clone()))
|
||||
.collect(),
|
||||
};
|
||||
let value =
|
||||
serde_json::to_value(&known).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
let mut registry: xai_grok_hooks::discovery::HookRegistry =
|
||||
serde_json::from_value(value).map_err(|e| WorkspaceError::HubError(e.to_string()))?;
|
||||
registry.recompile_matchers();
|
||||
|
|
@ -1763,6 +1817,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: std::path::PathBuf::from("/home/u/.grok/hooks"),
|
||||
extra_env: std::collections::HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||
layer: xai_grok_hooks::config::HookProvenance::File,
|
||||
};
|
||||
let mut registry = xai_grok_hooks::discovery::HookRegistry::default();
|
||||
registry.append_specs(vec![spec]);
|
||||
|
|
@ -1857,6 +1912,7 @@ mod tests {
|
|||
timeout_ms,
|
||||
source_dir,
|
||||
extra_env,
|
||||
layer,
|
||||
} = spec;
|
||||
let event = serde_json::from_value(serde_json::to_value(event).unwrap()).unwrap();
|
||||
HookSpecWire {
|
||||
|
|
@ -1872,6 +1928,7 @@ mod tests {
|
|||
timeout_ms,
|
||||
source_dir,
|
||||
extra_env,
|
||||
layer: layer.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
let spec = HookSpec {
|
||||
|
|
@ -1888,6 +1945,7 @@ mod tests {
|
|||
timeout_ms: 5000,
|
||||
source_dir: std::path::PathBuf::from("/home/u/.grok/hooks"),
|
||||
extra_env: std::collections::HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||
layer: xai_grok_hooks::config::HookProvenance::Managed,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&spec).unwrap(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue