diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 290b9c36..9d641599 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,13 @@ ## Unreleased +- Private contribution jobs can use `base_local_path`, an absolute checkout or + bare-repository path on the buyer daemon machine. The buyer uploads only the + pinned commit and its history, not other branches or uncommitted files. The + path stays local. An unavailable checkout falls back to the usual URL download. + It works only for a direct job (`seller_pubkey`); an open-pool post with + `base_local_path` is refused, because open-pool sellers must read the + original URL before claiming. Symlinks and submodules remain unsupported. + - Private contributions on larger repositories no longer time out. The relay packs a whole repository before its first byte (about 11 s for 37 MB and 75 s for 300 MB), which outlasted the 10 s client on two reads: diff --git a/crates/maxplayer-core/src/buyer/mod.rs b/crates/maxplayer-core/src/buyer/mod.rs index 225dba7d..4d73507c 100644 --- a/crates/maxplayer-core/src/buyer/mod.rs +++ b/crates/maxplayer-core/src/buyer/mod.rs @@ -463,6 +463,9 @@ struct PostJobParams { target_repo_owner: Option, #[serde(default)] target_repo_url: Option, + /// Local-only hint on the buyer daemon machine; never an offer field. + #[serde(default)] + base_local_path: Option, #[serde(default)] base_branch: Option, #[serde(default)] @@ -550,11 +553,15 @@ fn post_job_kind(params: &PostJobParams) -> Result { ¶ms.base_branch, ¶ms.base_oid, ) { + (None, None, None, None) if params.base_local_path.is_some() => { + Err("base_local_path is only valid in contribution mode".into()) + } (None, None, None, None) => Ok(JobKind::FromScratch), (Some(owner), Some(url), Some(branch), Some(oid)) => { Ok(JobKind::Contribution(ContributionSpec { target_repo_owner: owner.clone(), target_repo_url: url.clone(), + base_local_path: params.base_local_path.clone(), base_branch: branch.clone(), base_oid: oid.clone(), accepts: params.accepts.clone(), @@ -6748,6 +6755,29 @@ mod tests { // makes the lane reachable by a user; until it existed the daemon hardcoded `Sat`. // ———————————————————————————————————————————————————————————————————————————————————————— + #[test] + fn local_base_rpc_requires_contribution_and_preserves_path() { + let mut body = json!({"task":"t", "output":"git", "amount_sats":0, "base_local_path":"/private/checkout"}); + let params: PostJobParams = serde_json::from_value(body.clone()).unwrap(); + assert!( + post_job_kind(¶ms) + .unwrap_err() + .contains("only valid in contribution mode") + ); + body["target_repo_owner"] = json!("aa".repeat(32)); + body["target_repo_url"] = json!("https://example.test/private"); + body["base_branch"] = json!("main"); + body["base_oid"] = json!("bb".repeat(20)); + let params: PostJobParams = serde_json::from_value(body).unwrap(); + let JobKind::Contribution(spec) = post_job_kind(¶ms).unwrap() else { + panic!("contribution lost") + }; + assert_eq!( + spec.base_local_path.as_deref(), + Some(std::path::Path::new("/private/checkout")) + ); + } + /// The mode a `post_job` request body resolves to, going through the REAL deserializer — so a /// `#[serde(default)]` that stopped defaulting, or a field renamed on one side only, is caught /// here rather than by inspection. diff --git a/crates/maxplayer-core/src/git_transport.rs b/crates/maxplayer-core/src/git_transport.rs index 1ea5b698..ce777b36 100644 --- a/crates/maxplayer-core/src/git_transport.rs +++ b/crates/maxplayer-core/src/git_transport.rs @@ -48,8 +48,12 @@ //! A leaked branch-scoped token is bounded authority, not zero authority: it can replay a push to //! that one ref of that one repository until it expires. The binding above keeps it from leaving. +#[cfg(feature = "wallet")] +mod local_base; #[cfg(feature = "wallet")] mod pack_forward; +#[cfg(feature = "wallet")] +pub use local_base::prepare_private_input_base; use std::cell::RefCell; use std::io::{self, Read, Write}; @@ -846,6 +850,9 @@ fn push_gated_object( } let mut options = PushOptions::new(); options.remote_callbacks(callbacks); + if BUYER_INPUT_HTTP.get() { + options.packbuilder_parallelism(0); + } if lifetime.is_some() { silence_local_pack_abort_panics(); diff --git a/crates/maxplayer-core/src/git_transport/local_base.rs b/crates/maxplayer-core/src/git_transport/local_base.rs new file mode 100644 index 00000000..9318d516 --- /dev/null +++ b/crates/maxplayer-core/src/git_transport/local_base.rs @@ -0,0 +1,87 @@ +//! Import only the pinned commit's reachable set, never the source's packs/refs. +use super::*; +use git2::RepositoryOpenFlags; + +/// Stage locally if possible, otherwise retain the existing download path. A +/// failed import never leaves partial objects beside the downloaded pack. +pub fn prepare_private_input_base( + repo: &Repository, + local: Option<&Path>, + remote: &str, + commit: &str, + mint: Option, +) -> Result<(), TransportError> { + let reason = match local { + None => "no local path supplied", + Some(path) => match import(repo, path, commit) { + Ok(()) => return Ok(()), + Err(reason) => reason, + }, + }; + crate::opline!("buyer base: {reason}; downloading target_repo_url"); + fetch_private_input_base(repo, remote, commit, mint) +} + +fn import(destination: &Repository, path: &Path, commit: &str) -> Result<(), &'static str> { + if !path.is_absolute() { + return Err("local path is not absolute"); + } + // NO_SEARCH prevents a non-repo directory inside a checkout from silently + // selecting its ancestor. libgit2 opens bare repos and linked worktrees too. + let source = Repository::open_ext(path, RepositoryOpenFlags::NO_SEARCH, &[] as &[&Path]) + .map_err(|_| "local repository cannot be opened")?; + let oid = Oid::from_str(commit).map_err(|_| "local base pin is invalid")?; + source + .find_commit(oid) + .map_err(|_| "pinned commit is absent locally")?; + build(destination, &source, oid).map_err(|_| "local base could not be staged") +} + +fn build( + destination: &Repository, + source: &Repository, + oid: Oid, +) -> Result<(), Box> { + let scratch = tempfile::tempdir_in(destination.path())?; + // The view has no source config, refs, replace refs or shallow boundary. + // Borrow ONLY the object database. Missing ancestors therefore fail rather + // than producing a successful but incomplete shallow import. + let view = Repository::init_bare(scratch.path().join("view"))?; + view.set_odb(&source.odb()?)?; + // libgit2 1.8.1 honours windowMemory, but ignores pack.window/depth. + // A 1 MiB search window measured ~10s / 77.5 MB on agicash here, versus + // ~28s / 73.8 MB at the defaults. This affects only this temporary view. + view.config()?.set_i64("pack.windowMemory", 1024 * 1024)?; + let packed = Repository::init_bare(scratch.path().join("packed"))?; + let mut walk = view.revwalk()?; + walk.push(oid)?; + let mut builder = view.packbuilder()?; + builder.set_threads(0); + builder.insert_walk(&mut walk)?; + let odb = packed.odb()?; + let mut writer = odb.packwriter()?; + let mut bytes = 0usize; + let mut write_error = None; + builder.foreach(|chunk| { + bytes = bytes.saturating_add(chunk.len()); + if bytes > crate::private_content::MAX_GIT_TRANSFER_BYTES { + return false; + } + if let Err(error) = writer.write_all(chunk) { + write_error = Some(error); + return false; + } + true + })?; + if let Some(error) = write_error { + return Err(error.into()); + } + writer.commit()?; + // Atomically replace the empty pack directory. No loose objects, alternates + // or unrelated source packs are copied. All validation still runs afterward. + std::fs::rename( + packed.path().join("objects/pack"), + destination.path().join("objects/pack"), + )?; + Ok(()) +} diff --git a/crates/maxplayer-core/src/job_lifecycle.rs b/crates/maxplayer-core/src/job_lifecycle.rs index 01f8650f..b746fa4c 100644 --- a/crates/maxplayer-core/src/job_lifecycle.rs +++ b/crates/maxplayer-core/src/job_lifecycle.rs @@ -138,6 +138,8 @@ pub enum JobKind { pub struct ContributionSpec { pub target_repo_owner: String, pub target_repo_url: String, + /// Absolute checkout path on the buyer daemon. Never included in offer pins. + pub base_local_path: Option, pub base_branch: String, pub base_oid: String, pub accepts: Option>, @@ -684,6 +686,17 @@ pub async fn post_job_async( "post_job requires seller_pubkey (targeted default) or untargeted=true".into(), )); } + // Open-pool sellers check the base from target_repo_url before any seller is picked, so + // they never see a base read from a local checkout: the job would silently get no claims. + if request.untargeted + && matches!(&request.job, JobKind::Contribution(spec) if spec.base_local_path.is_some()) + { + return Err(JobLifecycleError::Input( + "base_local_path only works for a direct job (set seller_pubkey): in an open-pool job \ + sellers must be able to read target_repo_url themselves before claiming" + .into(), + )); + } match (&request.repo, &request.branch) { (Some(_), None) | (None, Some(_)) => { return Err(JobLifecycleError::Input( @@ -924,6 +937,15 @@ fn contribution_offer_from_spec( ) -> Result { use crate::contribution::{ContributionBase, ContributionOffer, TargetRepoPin, ACCEPTS_FORK}; + if spec + .base_local_path + .as_ref() + .is_some_and(|path| !path.is_absolute()) + { + return Err(JobLifecycleError::Input( + "base_local_path must be an absolute path on the buyer daemon machine".into(), + )); + } let owner = spec.target_repo_owner.trim().to_owned(); let url = spec.target_repo_url.trim().to_owned(); let branch = spec.base_branch.trim().to_owned(); @@ -5761,6 +5783,36 @@ mod tests { assert_eq!(claims[0].status, "processing"); } + #[test] + fn post_job_refuses_local_base_on_open_pool() { + let root = std::env::temp_dir().join(format!( + "maxplayer-jobs-local-open-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + let home = home::bootstrap(&root).expect("home"); + let mut request = contribution_post_request( + &"aa".repeat(32), + "https://example.test/private", + "main", + &"bb".repeat(20), + None, + ); + assert!(request.untargeted); + if let JobKind::Contribution(spec) = &mut request.job { + spec.base_local_path = Some("/private/checkout".into()); + } + let err = post_job(&home, request).expect_err("open-pool local base refused"); + let msg = err.to_string(); + assert!(msg.contains("base_local_path only works for a direct job"), "{msg}"); + assert!(msg.contains("seller_pubkey"), "{msg}"); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn post_job_refuses_missing_seller_without_untargeted() { let root = std::env::temp_dir().join(format!( @@ -6584,6 +6636,7 @@ mod tests { ContributionSpec { target_repo_owner: owner.into(), target_repo_url: url.into(), + base_local_path: None, base_branch: branch.into(), base_oid: oid.into(), accepts, @@ -6620,6 +6673,40 @@ mod tests { } } + #[test] + fn local_base_path_is_absolute_and_never_in_offer() { + let mut request = contribution_post_request( + &"aa".repeat(32), + "https://example.test/private", + "main", + &"bb".repeat(20), + None, + ); + let JobKind::Contribution(spec) = &request.job else { + unreachable!() + }; + let without = contribution_offer_from_spec(spec).unwrap(); + let before = build_offer_draft(&request, 10, Some(&without)).unwrap(); + let JobKind::Contribution(spec) = &mut request.job else { + unreachable!() + }; + spec.base_local_path = Some("/private/checkout".into()); + let with = contribution_offer_from_spec(spec).unwrap(); + assert_eq!(with, without, "local-only path must not enter wire pins"); + let after = build_offer_draft(&request, 10, Some(&with)).unwrap(); + assert_eq!(before, after, "event and tags must be unchanged"); + let JobKind::Contribution(spec) = &mut request.job else { + unreachable!() + }; + spec.base_local_path = Some("relative".into()); + assert!( + contribution_offer_from_spec(spec) + .unwrap_err() + .to_string() + .contains("absolute path") + ); + } + #[test] fn post_job_contribution_round_trip_offer_tags_bind_to_offer_values() { // The load-bearing round-trip: post_job contribution params -> BUILT event tags -> diff --git a/crates/maxplayer-core/src/private_content/posting.rs b/crates/maxplayer-core/src/private_content/posting.rs index 6cdcf7ae..d6e4d1f7 100644 --- a/crates/maxplayer-core/src/private_content/posting.rs +++ b/crates/maxplayer-core/src/private_content/posting.rs @@ -54,55 +54,69 @@ pub async fn post( ), None => None, }; - let imported_ref = if let (Some((source, oid)), Some(base_staging)) = - (&imported_base, &base_staging) - { - let base_repo = git2::Repository::init_bare(base_staging.path()) - .map_err(|_| Error("input staging unavailable"))?; - let mint = if crate::delivery_transport::is_relay_git_locator(source) { - let intended = source.clone(); - let signing = keys.clone(); - Some(std::sync::Arc::new(move |destination: &str| { - if !crate::git_transport::same_destination(&intended, destination) { - return Err("wrong base input source".into()); + let imported_ref = + if let (Some((source, oid)), Some(base_staging)) = (&imported_base, &base_staging) { + let base_repo = git2::Repository::init_bare(base_staging.path()) + .map_err(|_| Error("input staging unavailable"))?; + let mint = if crate::delivery_transport::is_relay_git_locator(source) { + let intended = source.clone(); + let signing = keys.clone(); + Some(std::sync::Arc::new(move |destination: &str| { + if !crate::git_transport::same_destination(&intended, destination) { + return Err("wrong base input source".into()); + } + crate::git_transport::nip98_authorization_header_with_keys( + destination, + &signing, + None, + None, + ) + .map_err(|e| e.to_string()) + }) as crate::git_transport::AuthMinter) + } else { + None + }; + let (source, oid) = (source.clone(), oid.clone()); + let local_path = match &request.job { + crate::job_lifecycle::JobKind::Contribution(spec) => spec.base_local_path.clone(), + crate::job_lifecycle::JobKind::FromScratch => None, + }; + let inputs_path = snapshot.as_ref().map(|_| staging.path().to_owned()); + let base_repo = tokio::task::spawn_blocking(move || { + crate::git_transport::prepare_private_input_base( + &base_repo, + local_path.as_deref(), + &source, + &oid, + mint, + ) + .map_err(|_| Error("pinned contribution base unavailable"))?; + let used = repositories::check_objects_after(&base_repo, Default::default())?; + // Base and inputs land in one job repository: check their combined quota + // before uploading either, not each staging repository alone. + if let Some(path) = inputs_path { + let inputs = git2::Repository::open_bare(path) + .map_err(|_| Error("input staging unavailable"))?; + repositories::check_objects_after(&inputs, used)?; } - crate::git_transport::nip98_authorization_header_with_keys( - destination, &signing, None, None, - ).map_err(|e| e.to_string()) - }) as crate::git_transport::AuthMinter) + Ok::<_, Error>(base_repo) + }) + .await + .map_err(|_| Error("base input worker unavailable"))??; + let reference = format!("refs/heads/input/{}", super::random_id()?); + base_repo + .reference( + &reference, + git2::Oid::from_str(&imported_base.as_ref().unwrap().1) + .map_err(|_| Error("invalid base pin"))?, + false, + "pinned input", + ) + .map_err(|_| Error("base input pin unavailable"))?; + Some((base_repo, reference)) } else { None }; - let (source, oid) = (source.clone(), oid.clone()); - let inputs_path = snapshot.as_ref().map(|_| staging.path().to_owned()); - let base_repo = tokio::task::spawn_blocking(move || { - crate::git_transport::fetch_private_input_base(&base_repo, &source, &oid, mint) - .map_err(|_| Error("pinned contribution base unavailable"))?; - let used = repositories::check_objects_after(&base_repo, Default::default())?; - // Base and inputs land in one job repository: check their combined quota - // before uploading either, not each staging repository alone. - if let Some(path) = inputs_path { - let inputs = git2::Repository::open_bare(path) - .map_err(|_| Error("input staging unavailable"))?; - repositories::check_objects_after(&inputs, used)?; - } - Ok::<_, Error>(base_repo) - }) - .await - .map_err(|_| Error("base input worker unavailable"))??; - let reference = format!("refs/heads/input/{}", super::random_id()?); - base_repo.reference( - &reference, - git2::Oid::from_str(&imported_base.as_ref().unwrap().1) - .map_err(|_| Error("invalid base pin"))?, - false, - "pinned input", - ) - .map_err(|_| Error("base input pin unavailable"))?; - Some((base_repo, reference)) - } else { - None - }; let pin = contribution.map(|c| super::Contribution { target_owner_pubkey: c.target.owner_pubkey().into(), target_clone_url: c.target.clone_url().into(), @@ -191,7 +205,8 @@ pub async fn post( ) .map_err(|e| e.to_string()) }); - let (snapshot, task, path) = (snapshot.clone(), task.clone(), staging.path().to_owned()); + let (snapshot, task, path) = + (snapshot.clone(), task.clone(), staging.path().to_owned()); let host = wire::HostPolicy { git_prefix: ctx.policy.host.git_prefix.clone(), accepted_mints: ctx.policy.host.accepted_mints.clone(), diff --git a/crates/maxplayer-core/tests/local_contribution_base.rs b/crates/maxplayer-core/tests/local_contribution_base.rs new file mode 100644 index 00000000..d60a9e06 --- /dev/null +++ b/crates/maxplayer-core/tests/local_contribution_base.rs @@ -0,0 +1,360 @@ +//! Full private posting with a local base, HTTPS fallback and receive-pack. +#![cfg(all(unix, feature = "wallet"))] +#[path = "git_http_fixture/mod.rs"] +mod git_http_fixture; +use git_http_fixture::{FixtureOptions, GitHttpAuthServer}; +use maxplayer_core::{contribution, gateway, home, job_lifecycle as jl, private_content as pc}; +use nostr_sdk::Keys; +use std::collections::BTreeSet; + +fn commit(repo: &git2::Repository, parent: Option, text: &str, mode: i32) -> git2::Oid { + let object = if mode == 0o160000 { + parent.unwrap() + } else { + repo.blob(text.as_bytes()).unwrap() + }; + let mut builder = repo.treebuilder(None).unwrap(); + builder.insert("file", object, mode).unwrap(); + let tree = repo.find_tree(builder.write().unwrap()).unwrap(); + let sig = git2::Signature::now("Fixture", "fixture@example.invalid").unwrap(); + let parent = parent.map(|oid| repo.find_commit(oid).unwrap()); + repo.commit( + None, + &sig, + &sig, + text, + &tree, + &parent.iter().collect::>(), + ) + .unwrap() +} +fn objects(repo: &git2::Repository) -> BTreeSet { + let mut ids = BTreeSet::new(); + repo.odb() + .unwrap() + .foreach(|id| { + ids.insert(*id); + true + }) + .unwrap(); + ids +} + +#[test] +fn local_base_post_uploads_only_pin_and_falls_back_without_leaking_or_bypassing_policy() { + let root = tempfile::tempdir().unwrap(); + let source = git2::Repository::init(root.path().join("checkout")).unwrap(); + let parent = commit(&source, None, "ancestor", 0o100644); + let base = commit(&source, Some(parent), "pinned", 0o100644); + source + .reference("refs/heads/main", base, true, "fixture") + .unwrap(); + let expected = objects(&source); + let secret = commit(&source, Some(parent), "PRIVATE OTHER BRANCH", 0o100644); + source + .reference("refs/heads/secret", secret, true, "fixture") + .unwrap(); + let unreachable = commit(&source, None, "UNREACHABLE PRIVATE", 0o100644); + std::fs::write( + source.workdir().unwrap().join("uncommitted-secret"), + "DO NOT UPLOAD", + ) + .unwrap(); + source.set_head("refs/heads/main").unwrap(); + let linked = root.path().join("linked"); + source.worktree("linked", &linked, None).unwrap(); + // Put both private branches and unreachable objects into a shared source + // pack. Copying that pack wholesale must fail the exact-set assertion below. + { + use std::io::Write; + let mut pack = source.packbuilder().unwrap(); + for oid in objects(&source) { + pack.insert_object(oid, None).unwrap(); + } + let odb = source.odb().unwrap(); + let mut writer = odb.packwriter().unwrap(); + pack.foreach(|chunk| writer.write_all(chunk).is_ok()) + .unwrap(); + writer.commit().unwrap(); + } + // The source's packing config must not control or be rewritten by import. + source + .config() + .unwrap() + .set_str("pack.windowMemory", "invalid-on-purpose") + .unwrap(); + let empty = git2::Repository::init_bare(root.path().join("empty")).unwrap(); + let nonrepo = root.path().join("nonrepo"); + std::fs::create_dir(&nonrepo).unwrap(); + // Remote source contains only the pin, not the secret branch. + let remote_repo = git2::Repository::init_bare(root.path().join("remote")).unwrap(); + for id in &expected { + let odb = source.odb().unwrap(); + let obj = odb.read(*id).unwrap(); + remote_repo + .odb() + .unwrap() + .write(obj.kind(), obj.data()) + .unwrap(); + } + remote_repo + .reference("refs/heads/main", base, true, "fixture") + .unwrap(); + remote_repo + .config() + .unwrap() + .set_bool("uploadpack.allowReachableSHA1InWant", true) + .unwrap(); + let remote = GitHttpAuthServer::spawn_with( + remote_repo.path(), + "/public/source", + FixtureOptions { + allow_anonymous: true, + ..Default::default() + }, + ); + let incomplete = git2::Repository::init_bare(root.path().join("incomplete")).unwrap(); + for id in &expected { + if *id == parent { + continue; + } + let odb = source.odb().unwrap(); + let object = odb.read(*id).unwrap(); + incomplete + .odb() + .unwrap() + .write(object.kind(), object.data()) + .unwrap(); + } + std::fs::write(incomplete.path().join("shallow"), format!("{base}\n")).unwrap(); + let modes = [ + ( + "checkout", + Some(source.workdir().unwrap().to_owned()), + base, + false, + true, + ), + ("linked", Some(linked), base, false, true), + ( + "bare", + Some(remote_repo.path().to_owned()), + base, + false, + true, + ), + ("absent", None, base, true, true), + ( + "missing", + Some(root.path().join("missing")), + base, + true, + true, + ), + ("nonrepo", Some(nonrepo), base, true, true), + ( + "missing-commit", + Some(empty.path().to_owned()), + base, + true, + true, + ), + ( + "incomplete-history", + Some(incomplete.path().to_owned()), + base, + true, + true, + ), + ( + "symlink", + Some(source.workdir().unwrap().to_owned()), + commit(&source, Some(base), "target", 0o120000), + false, + false, + ), + ( + "submodule", + Some(source.workdir().unwrap().to_owned()), + commit(&source, Some(base), "submodule", 0o160000), + false, + false, + ), + ]; + let servers: Vec<_> = modes + .iter() + .map(|(name, ..)| { + let repo = git2::Repository::init_bare(root.path().join(format!("destination-{name}"))) + .unwrap(); + let server = GitHttpAuthServer::spawn_with( + repo.path(), + "/git/buyer/job", + FixtureOptions { + private_job_host: true, + ..Default::default() + }, + ); + (repo, server) + }) + .collect(); + let ca = root.path().join("ca.pem"); + let mut certs = std::fs::read_to_string(remote.ca_file(root.path())).unwrap(); + for (_, server) in &servers { + certs.push_str(&std::fs::read_to_string(server.ca_file(root.path())).unwrap()); + } + std::fs::write(&ca, certs).unwrap(); + unsafe { + std::env::set_var("SSL_CERT_FILE", &ca); + std::env::set_var("NO_PROXY", "127.0.0.1,localhost"); + std::env::set_var("no_proxy", "127.0.0.1,localhost"); + } + let buyer = Keys::generate(); + let seller = Keys::generate(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + for ((name, local, oid, fallback, succeeds), (destination, server)) in + modes.into_iter().zip(&servers) + { + let mut home = home::bootstrap(root.path().join(format!("home-{name}"))).unwrap(); + // Delivery/publication over Nostr is outside this Git fixture. Queue durably + // and fail connection setup immediately rather than waiting for NIP-42. + home.config.relay_url = "not a relay URL".into(); + home.config.privacy.private_jobs = true; + home.config.privacy.private_content_v2 = true; + home.config.privacy.private_job_repos = true; + home.config.privacy.service_pubkey = Some(Keys::generate().public_key().to_hex()); + home.config.privacy.git_base = Some(format!( + "{}/git/", + server.repo_url().split("/git/").next().unwrap() + )); + let url = if fallback { + remote.repo_url() + } else { + "https://127.0.0.1:1/not-fetchable".into() + }; + let pin = contribution::ContributionOffer { + target: contribution::TargetRepoPin::new(buyer.public_key().to_hex(), url.clone()) + .unwrap(), + base: contribution::ContributionBase::new("main", oid.to_string()).unwrap(), + accepts: vec!["fork".into()], + }; + let request = jl::PostJobRequest { + visibility: Some(pc::wire::Visibility::Private), + output_category: Some(pc::wire::Output::Code), + inputs: vec![], + task: "contribute".into(), + output: "git".into(), + amount_sats: 0, + seller_pubkey: Some(seller.public_key().to_hex()), + untargeted: false, + deadline_unix: Some(4_000_000_000), + repo: None, + branch: None, + job: jl::JobKind::Contribution(jl::ContributionSpec { + target_repo_owner: buyer.public_key().to_hex(), + target_repo_url: url, + base_local_path: local.clone(), + base_branch: "main".into(), + base_oid: oid.to_string(), + accepts: None, + }), + requested_agent: None, + requested_harness_family: None, + requested_model: None, + required_capabilities: vec![], + accepts_delivery: vec![], + payment_mode: gateway::PaymentMode::None, + }; + let mut offer = gateway::OfferDraft::untargeted("contribute", "git", 0, 4_000_000_000) + .with_payment_mode(gateway::PaymentMode::None); + offer.seller_pubkey = request.seller_pubkey.clone(); + let before = remote.requests().len(); + let result = runtime.block_on(pc::posting::post( + &home, + &buyer, + &request, + &offer.to_event_draft(), + Some(&pin), + )); + if !succeeds { + let error = result.unwrap_err().to_string(); + assert!(error.contains("symlinks, submodules"), "{name}: {error}"); + assert!( + server.requests().is_empty(), + "{name}: policy must run before upload" + ); + continue; + } + let outcome = result.unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!( + remote.requests().len() > before, + fallback, + "{name}: source selection" + ); + assert_eq!( + objects(destination), + expected, + "{name}: exact reachable set including history" + ); + assert!(!destination.odb().unwrap().exists(secret)); + assert!(!destination.odb().unwrap().exists(unreachable)); + assert_eq!( + destination + .references() + .unwrap() + .next() + .unwrap() + .unwrap() + .target(), + Some(base) + ); + let pushes: Vec<_> = server + .requests() + .into_iter() + .filter(|r| r.method == "POST" && r.target.ends_with("git-receive-pack")) + .collect(); + assert_eq!(pushes.len(), 1); + assert_eq!( + pushes[0].push_capabilities.as_deref(), + Some("report-status ofs-delta"), + "{name}: single pack must forward" + ); + let store = + pc::store::ContentStore::open(&home.root.join("private-content.sqlite")).unwrap(); + let event = store.event(&outcome.job_id).unwrap().unwrap(); + let mut ctx = + pc::channel::ContentContext::open(&home, &buyer.public_key().to_hex()).unwrap(); + let task = ctx + .accept_content( + &event, + &event, + None, + None, + None, + nostr_sdk::Timestamp::now().as_secs(), + ) + .unwrap(); + let json = format!( + "{}{}", + serde_json::to_string(&event).unwrap(), + serde_json::to_string(task.body()).unwrap() + ); + assert!(!json.contains("base_local_path")); + if let Some(local) = local { + assert!(!json.contains(local.to_str().unwrap())); + } + assert!( + home.root + .join("store-seeds") + .join(base.to_string()) + .is_dir() + ); + } + assert_eq!( + source + .config() + .unwrap() + .get_string("pack.windowMemory") + .unwrap(), + "invalid-on-purpose" + ); +} diff --git a/crates/maxplayer/src/mcp.rs b/crates/maxplayer/src/mcp.rs index 2da6995b..f3938f5f 100644 --- a/crates/maxplayer/src/mcp.rs +++ b/crates/maxplayer/src/mcp.rs @@ -225,7 +225,7 @@ fn tools() -> Value { json!([ { "name": "post_job", - "description": with_guides("Publish a real maxplayer job offer (OFFER kind) to the configured maxplayer relay, then let the buyer daemon drive the award: once a payable seller claim appears the daemon auto-awards it under the hood, so the normal flow is just post_job then collect (two calls). max_sats caps what the daemon will commit to (defaults to amount_sats); it never auto-awards a claim it cannot pay. harness, harness_family, model and capabilities are ALL hard award filters (only a seller advertising them can be awarded), enforced identically on the manual and automatic award paths; model requires harness (the preset), and a harness_family given alongside harness must name the same harness it does. Omit them all and every claim passes exactly as before. Targeted seller p-tag is the documented default (pass seller_pubkey); set untargeted=true for an open offer. Optional repo+branch attach git delivery tags. CONTRIBUTION (freelance-PR) mode: supply target_repo_owner + target_repo_url + base_branch + base_oid to post a job-class=contribution offer against a repo you own (seller forks it and delivers a PR); these four are ALL-OR-NOTHING (a partial set is refused). Omit all four ⇒ from-scratch job. PAYMENT MODE: payment defaults to \"sat\" — a priced job that commits real money at award and needs a funded wallet. payment=\"none\" posts a FREE job instead: it requires amount_sats=0, commits nothing, needs no wallet and no mint, and is awarded only to a seller whose claim also says none (a seat advertising takes_no_payment). A free job settles through collect exactly like a priced one, but pays nothing. Slow preparation returns status=preparing with preparation_id; this is NOT a published job. Poll get_job with job_id=preparation_id until status=posted yields the real job_id, then use that job_id normally. Never repost with changed arguments to poll. An identical retry attaches to the same preparation (status=posted with deduplicated=true means nothing new was published); without request_id that only holds for 10 minutes after it finishes, so a later identical post is a new hire. A failure before any offer was queued re-runs on retry. request_id is a permanent idempotency key; a new one is a deliberate new hire. Never echoes secrets."), + "description": with_guides("Publish a real maxplayer job offer (OFFER kind) to the configured maxplayer relay, then let the buyer daemon drive the award: once a payable seller claim appears the daemon auto-awards it under the hood, so the normal flow is just post_job then collect (two calls). max_sats caps what the daemon will commit to (defaults to amount_sats); it never auto-awards a claim it cannot pay. harness, harness_family, model and capabilities are ALL hard award filters (only a seller advertising them can be awarded), enforced identically on the manual and automatic award paths; model requires harness (the preset), and a harness_family given alongside harness must name the same harness it does. Omit them all and every claim passes exactly as before. Targeted seller p-tag is the documented default (pass seller_pubkey); set untargeted=true for an open offer. Optional repo+branch attach git delivery tags. CONTRIBUTION (freelance-PR) mode: supply target_repo_owner + target_repo_url + base_branch + base_oid to post a job-class=contribution offer against a repo you own (seller forks it and delivers a PR); these four are ALL-OR-NOTHING (a partial set is refused). Omit all four ⇒ from-scratch job. Optional base_local_path is an absolute checkout or bare-repo path on the buyer daemon machine, valid only in contribution mode. For private posts it supplies only the pinned base and its history; the path stays local. If unavailable, the buyer downloads target_repo_url as usual. Direct jobs only: an open-pool (untargeted) post with base_local_path is refused, because open-pool sellers must read target_repo_url themselves before claiming. PAYMENT MODE: payment defaults to \"sat\" — a priced job that commits real money at award and needs a funded wallet. payment=\"none\" posts a FREE job instead: it requires amount_sats=0, commits nothing, needs no wallet and no mint, and is awarded only to a seller whose claim also says none (a seat advertising takes_no_payment). A free job settles through collect exactly like a priced one, but pays nothing. Slow preparation returns status=preparing with preparation_id; this is NOT a published job. Poll get_job with job_id=preparation_id until status=posted yields the real job_id, then use that job_id normally. Never repost with changed arguments to poll. An identical retry attaches to the same preparation (status=posted with deduplicated=true means nothing new was published); without request_id that only holds for 10 minutes after it finishes, so a later identical post is a new hire. A failure before any offer was queued re-runs on retry. request_id is a permanent idempotency key; a new one is a deliberate new hire. Never echoes secrets."), "inputSchema": { "type": "object", "properties": { @@ -293,6 +293,10 @@ fn tools() -> Value { "type": "string", "description": "Contribution mode: owner pubkey (64 hex) of the target repo you own. Requires target_repo_url + base_branch + base_oid." }, + "base_local_path": { + "type": "string", + "description": "Contribution only: absolute path to a local checkout (including a linked worktree) or bare repo on the buyer daemon machine. Private posts use the pinned commit and its history, never other branches or uncommitted files. The path stays local. Falls back to target_repo_url if unusable. Direct jobs only (seller_pubkey): refused with untargeted=true. Public posts do not upload a base." + }, "target_repo_url": { "type": "string", "description": "Contribution mode: https/relay-git clone URL of the target repo (ext::/file/ssh refused). Requires target_repo_owner + base_branch + base_oid." @@ -578,6 +582,25 @@ mod tests { use std::io::Cursor; use std::sync::atomic::{AtomicU64, Ordering}; + #[test] + fn local_base_mcp_schema_exposes_local_only_contribution_hint() { + let list = tools(); + let post = list + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "post_job") + .unwrap(); + let param = &post["inputSchema"]["properties"]["base_local_path"]; + assert_eq!(param["type"], "string"); + assert!( + param["description"] + .as_str() + .unwrap() + .contains("buyer daemon") + ); + } + // The post_job schema promises that EVERY request axis constrains award selection (#897 wired // the last of them). Keep those caller-facing claims tied to the real award predicate: removing // a predicate or weakening a description makes this test fail. diff --git a/docs/BUYER-QUICKSTART.md b/docs/BUYER-QUICKSTART.md index 11120d62..bc063b39 100644 --- a/docs/BUYER-QUICKSTART.md +++ b/docs/BUYER-QUICKSTART.md @@ -247,3 +247,40 @@ error then names the offer id: inspect it with `get_job`, and award it with The daemon cleans abandoned directories in `private-input-staging/` on startup, using per-directory locks to preserve live work. Legacy unlabelled `.tmp*` directories are left untouched rather than guessing which files are safe to delete. + +## Use a local checkout for a private contribution + +When the target repository is private, pass `base_local_path` to `post_job` +alongside all four contribution pins: `target_repo_owner`, `target_repo_url`, +`base_branch`, and `base_oid`. Use an absolute path on the **machine running the +buyer daemon**, not on a remote MCP client. A normal checkout, linked worktree, +or bare repository works. For example: + +```json +{ + "visibility": "private", + "output_category": "code", + "base_local_path": "/home/me/projects/my-repo" +} +``` + +These are additions to your usual job arguments and contribution pins, not a +complete post request. Keep `target_repo_url` as the repository's HTTPS URL. +The local repository must contain the exact `base_oid` commit and its history. +The buyer copies only objects reachable from that commit: other branches, +unreachable objects and uncommitted changes are not uploaded. It does not check +the pin against the remote, run Git hooks, or require the Git command-line tool. +The local path is never included in the offer, seller task, or relay request. + +If the path is missing, cannot be opened, or lacks the commit or its history, the +buyer logs one line and tries the usual URL download. A private upstream without +a usable local copy can still fail because the built-in downloader does not use +your SSH keys or credential helper. Relative paths and paths supplied without +contribution pins are refused. Public posts do not upload a base. + +`base_local_path` works only for a direct job: set `seller_pubkey`. That +seller reads the uploaded base from the private job repository. An open-pool +post (`untargeted=true`) with `base_local_path` is refused before anything is +uploaded, because open-pool sellers fetch the original URL themselves before +claiming and would never see the local copy. Existing size limits and the refusal of +symlinks or submodules anywhere in the base history still apply.