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:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -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());

View 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);
}
}

View file

@ -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]);
}

View file

@ -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,

View file

@ -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;

View file

@ -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}");
}
}
}
})

View file

@ -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};

View file

@ -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(_)));
}
}

View file

@ -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]

View file

@ -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]

View file

@ -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(),