Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/agentic-jujutsu/src/agent_coordination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,13 +433,16 @@ mod tests {
async fn test_conflict_detection() {
let coord = AgentCoordination::new();

// Register operation
let op = JJOperation::new(
// Register operation. The second argument of `JJOperation::new` is the
// *command*, not the operation type, so the type must be set explicitly
// for the conflict analyzer to see this as an `edit` operation.
let mut op = JJOperation::new(
"op-1".to_string(),
"edit".to_string(),
"jj edit".to_string(),
"test".to_string(),
"localhost".to_string(),
);
op.set_operation_type("edit".to_string());

coord.register_operation("agent-1", &op, vec!["file.rs".to_string()])
.await
Expand Down
10 changes: 9 additions & 1 deletion packages/agentic-jujutsu/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,15 @@ mod tests {
#[test]
fn test_default_config() {
let config = JJConfig::default();
assert_eq!(config.jj_path, "jj");
// The default jj path is either the system "jj" (on PATH) or, when a
// binary has already been extracted, the cached path ending in "jj".
// Asserting equality with "jj" makes this test depend on global cache
// state (e.g. whether another test extracted the embedded binary).
assert!(
config.jj_path == "jj" || config.jj_path.ends_with("/jj"),
"unexpected default jj_path: {}",
config.jj_path
);
assert_eq!(config.timeout_ms, 30000);
assert!(!config.verbose);
}
Expand Down
19 changes: 19 additions & 0 deletions packages/agentic-jujutsu/src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
//! tamper-proof audit trails. ML-DSA is a NIST-approved post-quantum digital
//! signature algorithm.
//!
//! # ⚠️ SECURITY — PLACEHOLDER IMPLEMENTATION
//!
//! The signing/verification in this module is **NOT cryptographically secure**.
//! `generate_signing_keypair` returns two *unrelated* random byte strings,
//! `sign_message_internal` produces a trivially-reversible byte sum, and
//! `verify_signature_internal` only checks structural length — it accepts ANY
//! well-formed signature regardless of message or key. Do not rely on it for
//! authenticity or tamper-evidence. Real ML-DSA must be wired in via a vetted
//! PQC crate (e.g. RustCrypto `ml-dsa`, sizes already match ML-DSA-44) or the
//! `@qudag/napi-core` JS layer before any production use.
//!
//! # Examples
//!
//! ```rust
Expand Down Expand Up @@ -328,7 +339,12 @@ mod tests {
assert!(valid);
}

// IGNORED: the placeholder `verify_signature_internal` is not cryptographically
// sound — it accepts any length-valid signature, so it cannot reject a tampered
// message. This test encodes the INTENDED contract and will pass once a real
// ML-DSA implementation replaces the placeholder. See the module-level SECURITY note.
#[test]
#[ignore = "placeholder crypto cannot detect tampering; needs real ML-DSA (see SECURITY note)"]
fn test_verify_invalid_signature() {
let keypair = generate_signing_keypair();
let message = b"Test message";
Expand All @@ -341,7 +357,10 @@ mod tests {
assert!(!valid);
}

// IGNORED: same root cause as `test_verify_invalid_signature` — the placeholder
// verify ignores the public key entirely, so it cannot reject a wrong key.
#[test]
#[ignore = "placeholder crypto ignores the public key; needs real ML-DSA (see SECURITY note)"]
fn test_verify_wrong_public_key() {
let keypair1 = generate_signing_keypair();
let keypair2 = generate_signing_keypair();
Expand Down
14 changes: 13 additions & 1 deletion packages/agentic-jujutsu/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,20 @@ impl JJOperation {
}

/// Set operation type from string
///
/// Recognized type names are canonicalized (e.g. `"describe"` -> `"Describe"`)
/// so the stored value matches the `OperationType` enum representation used
/// elsewhere. Unrecognized (custom) type strings are preserved as-is rather
/// than being silently collapsed to `"Unknown"`.
pub fn set_operation_type(&mut self, type_str: String) {
self.operation_type = type_str;
let parsed = OperationType::from_string(&type_str);
self.operation_type = if parsed == OperationType::Unknown
&& !type_str.eq_ignore_ascii_case("unknown")
{
type_str
} else {
parsed.as_string()
};
}

/// Get timestamp as ISO 8601 string
Expand Down
79 changes: 73 additions & 6 deletions packages/agentic-jujutsu/src/wrapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ pub struct JJWrapper {
reasoning_bank: Arc<ReasoningBank>,
current_trajectory: Arc<Mutex<Option<Trajectory>>>,
agent_coordination: Arc<tokio::sync::Mutex<Option<AgentCoordination>>>,
/// Cached result of whether the configured jj uses the `bookmark` subcommand
/// (jj >= 0.21) instead of the legacy `branch` subcommand. Probed once lazily.
bookmark_subcommand: Arc<tokio::sync::OnceCell<bool>>,
}

#[napi]
Expand Down Expand Up @@ -138,6 +141,7 @@ impl JJWrapper {
reasoning_bank,
current_trajectory,
agent_coordination,
bookmark_subcommand: Arc::new(tokio::sync::OnceCell::new()),
})
}

Expand Down Expand Up @@ -427,27 +431,30 @@ impl JJWrapper {
self.execute(args).await
}

/// Create a branch
/// Create a branch (bookmark on jj >= 0.21)
#[napi(js_name = "branchCreate")]
pub async fn branch_create(&self, name: String, revision: Option<String>) -> napi::Result<JJResult> {
let mut args = vec!["branch".to_string(), "create".to_string(), name];
let sub = self.bookmark_subcommand_name().await;
let mut args = vec![sub.to_string(), "create".to_string(), name];
if let Some(rev) = revision {
args.push("-r".to_string());
args.push(rev);
}
self.execute(args).await
}

/// Delete a branch
/// Delete a branch (bookmark on jj >= 0.21)
#[napi(js_name = "branchDelete")]
pub async fn branch_delete(&self, name: String) -> napi::Result<JJResult> {
self.execute(vec!["branch".to_string(), "delete".to_string(), name]).await
let sub = self.bookmark_subcommand_name().await;
self.execute(vec![sub.to_string(), "delete".to_string(), name]).await
}

/// List branches
/// List branches (bookmarks on jj >= 0.21)
#[napi(js_name = "branchList")]
pub async fn branch_list(&self) -> napi::Result<Vec<JJBranch>> {
let result = self.execute(vec!["branch".to_string(), "list".to_string()]).await?;
let sub = self.bookmark_subcommand_name().await;
let result = self.execute(vec![sub.to_string(), "list".to_string()]).await?;
Self::parse_branches(&result.stdout)
.map_err(|e| napi::Error::from_reason(format!("Failed to parse branches: {}", e)))
}
Expand Down Expand Up @@ -1219,8 +1226,55 @@ impl JJWrapper {
reasoning_bank,
current_trajectory,
agent_coordination,
bookmark_subcommand: Arc::new(tokio::sync::OnceCell::new()),
})
}

/// Decide whether a jj version string uses the `bookmark` subcommand.
///
/// jj renamed `branch` -> `bookmark` in 0.21. Given the `jj --version`
/// output (e.g. `"jj 0.35.0"`), returns `true` for jj >= 0.21. Falls back
/// to `true` (modern jj) when the version cannot be parsed.
fn version_uses_bookmark(version_output: &str) -> bool {
let parsed = version_output.split_whitespace().find_map(|tok| {
let mut parts = tok.split('.');
let major = parts
.next()?
.trim_start_matches(|c: char| !c.is_ascii_digit())
.parse::<u32>()
.ok()?;
let minor = parts.next()?.parse::<u32>().ok()?;
Some((major, minor))
});

match parsed {
Some((major, minor)) => major > 0 || minor >= 21,
None => true,
}
}

/// Lazily probe `jj --version` (once) to decide between the modern
/// `bookmark` subcommand and the legacy `branch` subcommand.
async fn bookmark_subcommand_name(&self) -> &'static str {
let uses_bookmark = *self
.bookmark_subcommand
.get_or_init(|| async {
let timeout =
std::time::Duration::from_millis(self.config.timeout_ms as u64);
match execute_jj_command(&self.config.jj_path, &["--version"], timeout).await {
Ok(output) => Self::version_uses_bookmark(&output),
// If the probe fails, assume modern jj (the bundled binary is current).
Err(_) => true,
}
})
.await;

if uses_bookmark {
"bookmark"
} else {
"branch"
}
}
}

impl Default for JJWrapper {
Expand Down Expand Up @@ -1296,4 +1350,17 @@ mod tests {
assert_eq!(branches[1].name, "origin/main");
assert!(branches[1].is_remote);
}

#[test]
fn test_version_uses_bookmark() {
// jj >= 0.21 uses the `bookmark` subcommand.
assert!(JJWrapper::version_uses_bookmark("jj 0.35.0"));
assert!(JJWrapper::version_uses_bookmark("jj 0.21.0"));
assert!(JJWrapper::version_uses_bookmark("jj 1.0.0"));
// Older jj still uses the legacy `branch` subcommand.
assert!(!JJWrapper::version_uses_bookmark("jj 0.20.0"));
assert!(!JJWrapper::version_uses_bookmark("jj 0.15.1"));
// Unparseable output falls back to modern (bookmark).
assert!(JJWrapper::version_uses_bookmark("unknown"));
}
}
Loading